4runr-os 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,921 +1,921 @@
1
- /// Agent Builder Screen
2
- /// Multi-step wizard for creating and editing agents
3
-
4
- use ratatui::prelude::*;
5
- use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
6
- use crate::app::AppState;
7
-
8
- // === 4RUNR BRAND COLORS (matching layout.rs) ===
9
- const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
- const 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
- const BG_PANEL: Color = Color::Rgb(18, 18, 25);
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
-
137
- /// Agent Builder wizard state
138
- #[derive(Debug, Clone)]
139
- pub struct AgentBuilderState {
140
- pub current_step: usize, // 1-6
141
- pub focused_field: usize,
142
-
143
- // Step 1: Basic Info
144
- pub name: String,
145
- pub description: String,
146
-
147
- // Step 2: Model Selection
148
- pub use_local_model: bool,
149
- pub provider: String, // "openai", "anthropic", "ollama", etc.
150
- pub model: String, // "gpt-4", "claude-3", "llama2", etc.
151
- pub local_provider: String, // "ollama", "lm-studio", "custom"
152
- pub local_model: String,
153
- pub local_url: String,
154
-
155
- // Step 3: System Prompt
156
- pub system_prompt: String,
157
-
158
- // Step 4: Parameters
159
- pub temperature: f32,
160
- pub max_tokens: u32,
161
- pub top_p: f32,
162
- pub frequency_penalty: f32,
163
- pub presence_penalty: f32,
164
-
165
- // Step 5: Tools
166
- pub selected_tools: Vec<String>,
167
- pub available_tools: Vec<String>,
168
-
169
- // Validation
170
- pub validation_errors: Vec<String>,
171
- }
172
-
173
- impl Default for AgentBuilderState {
174
- fn default() -> Self {
175
- Self {
176
- current_step: 1,
177
- focused_field: 0,
178
- name: String::new(),
179
- description: String::new(),
180
- use_local_model: false,
181
- provider: "openai".to_string(),
182
- model: "gpt-4".to_string(),
183
- local_provider: "ollama".to_string(),
184
- local_model: "llama2".to_string(),
185
- local_url: "http://localhost:11434".to_string(),
186
- system_prompt: String::new(),
187
- temperature: 0.7,
188
- max_tokens: 2000,
189
- top_p: 1.0,
190
- frequency_penalty: 0.0,
191
- presence_penalty: 0.0,
192
- selected_tools: Vec::new(),
193
- available_tools: vec![
194
- "calculator".to_string(),
195
- "time".to_string(),
196
- "weather".to_string(),
197
- ],
198
- validation_errors: Vec::new(),
199
- }
200
- }
201
- }
202
-
203
- impl AgentBuilderState {
204
- /// Validate current step
205
- pub fn validate_step(&mut self) -> bool {
206
- self.validation_errors.clear();
207
-
208
- match self.current_step {
209
- 1 => {
210
- if self.name.trim().is_empty() {
211
- self.validation_errors.push("Name is required".to_string());
212
- }
213
- if self.name.len() > 50 {
214
- self.validation_errors.push("Name must be 50 characters or less".to_string());
215
- }
216
- }
217
- 2 => {
218
- if !self.use_local_model && self.model.is_empty() {
219
- self.validation_errors.push("Model is required".to_string());
220
- }
221
- if self.use_local_model && self.local_model.is_empty() {
222
- self.validation_errors.push("Local model is required".to_string());
223
- }
224
- }
225
- 3 => {
226
- // System prompt is optional
227
- }
228
- 4 => {
229
- if !(0.0..=2.0).contains(&self.temperature) {
230
- self.validation_errors.push("Temperature must be between 0.0 and 2.0".to_string());
231
- }
232
- if self.max_tokens < 1 || self.max_tokens > 100000 {
233
- self.validation_errors.push("Max tokens must be between 1 and 100000".to_string());
234
- }
235
- }
236
- 5 => {
237
- // Tools are optional
238
- }
239
- 6 => {
240
- // Review step - validate all
241
- if self.name.trim().is_empty() {
242
- self.validation_errors.push("Name is required".to_string());
243
- }
244
- }
245
- _ => {}
246
- }
247
-
248
- self.validation_errors.is_empty()
249
- }
250
-
251
- /// Move to next step
252
- pub fn next_step(&mut self) -> bool {
253
- if self.validate_step() && self.current_step < 6 {
254
- self.current_step += 1;
255
- self.focused_field = 0;
256
- true
257
- } else {
258
- false
259
- }
260
- }
261
-
262
- /// Move to previous step
263
- pub fn prev_step(&mut self) {
264
- if self.current_step > 1 {
265
- self.current_step -= 1;
266
- self.focused_field = 0;
267
- }
268
- }
269
-
270
- /// Move to next field within current step
271
- pub fn next_field(&mut self) {
272
- let max_field = self.get_max_field_for_step();
273
- if self.focused_field < max_field {
274
- self.focused_field += 1;
275
- }
276
- }
277
-
278
- /// Move to previous field within current step
279
- pub fn prev_field(&mut self) {
280
- if self.focused_field > 0 {
281
- self.focused_field -= 1;
282
- }
283
- }
284
-
285
- /// Get the maximum field index for the current step
286
- fn get_max_field_for_step(&self) -> usize {
287
- match self.current_step {
288
- 1 => 1, // Name, Description
289
- 2 => 4, // Provider, Model, Local Provider, Local Model, Local URL
290
- 3 => 0, // System Prompt (single field)
291
- 4 => 4, // Temperature, Max Tokens, Top P, Frequency Penalty, Presence Penalty
292
- 5 => self.available_tools.len().saturating_sub(1), // Tools list
293
- 6 => 0, // Review (no editable fields)
294
- _ => 0,
295
- }
296
- }
297
- }
298
-
299
- /// Render the Agent Builder screen
300
- pub fn render(f: &mut Frame, state: &AppState) {
301
- let area = f.size();
302
-
303
- // Get wizard state from AppState
304
- let wizard_state = &state.agent_builder;
305
-
306
- render_wizard(f, area, wizard_state);
307
- }
308
-
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
-
344
- // Main container with brand styling
345
- let block = Block::default()
346
- .title(format!(" 🤖 Agent Builder - Step {}/6 {} ", wizard.current_step, progress_bar))
347
- .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
348
- .borders(Borders::ALL)
349
- .border_style(Style::default().fg(BRAND_PURPLE))
350
- .style(Style::default().bg(BG_PANEL));
351
-
352
- let inner = block.inner(h_chunks[0]);
353
- f.render_widget(block, h_chunks[0]);
354
-
355
- // Render current step
356
- match wizard.current_step {
357
- 1 => render_step_1_basic_info(f, inner, wizard),
358
- 2 => render_step_2_model_selection(f, inner, wizard),
359
- 3 => render_step_3_system_prompt(f, inner, wizard),
360
- 4 => render_step_4_parameters(f, inner, wizard),
361
- 5 => render_step_5_tools(f, inner, wizard),
362
- 6 => render_step_6_review(f, inner, wizard),
363
- _ => {}
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);
567
- }
568
-
569
- fn render_step_1_basic_info(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
570
- let focused = wizard.focused_field;
571
-
572
- let text = vec![
573
- Line::from(""),
574
- Line::from("Step 1: Basic Information").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
575
- Line::from(""),
576
- Line::from(vec![
577
- Span::styled(if focused == 0 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
578
- Span::raw("Name: "),
579
- Span::styled(&wizard.name, Style::default().fg(NEON_GREEN)),
580
- Span::styled(if focused == 0 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
581
- ]),
582
- Line::from(" (Required, max 50 characters)").style(Style::default().fg(TEXT_DIM)),
583
- Line::from(""),
584
- Line::from(vec![
585
- Span::styled(if focused == 1 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
586
- Span::raw("Description: "),
587
- Span::styled(&wizard.description, Style::default().fg(NEON_GREEN)),
588
- Span::styled(if focused == 1 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
589
- ]),
590
- Line::from(" (Optional, brief description of agent purpose)").style(Style::default().fg(TEXT_DIM)),
591
- Line::from(""),
592
- Line::from(""),
593
- Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
594
- Line::from(" Tab/Shift+Tab/↑↓ - Move between fields").style(Style::default().fg(TEXT_DIM)),
595
- Line::from(" Enter - Next step").style(Style::default().fg(TEXT_DIM)),
596
- Line::from(" ESC - Cancel and return to Main").style(Style::default().fg(TEXT_DIM)),
597
- ];
598
-
599
- // Show validation errors if any
600
- let mut all_lines = text;
601
- if !wizard.validation_errors.is_empty() {
602
- all_lines.push(Line::from(""));
603
- all_lines.push(Line::from("Validation Errors:").style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)));
604
- for error in &wizard.validation_errors {
605
- all_lines.push(Line::from(format!(" • {}", error)).style(Style::default().fg(Color::Red)));
606
- }
607
- }
608
-
609
- let paragraph = Paragraph::new(all_lines)
610
- .wrap(Wrap { trim: false })
611
- .alignment(Alignment::Left);
612
-
613
- f.render_widget(paragraph, area);
614
- }
615
-
616
- fn render_step_2_model_selection(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
617
- let focused = wizard.focused_field;
618
- let model_type = if wizard.use_local_model { "Local" } else { "Remote" };
619
-
620
- let text = vec![
621
- Line::from(""),
622
- Line::from("Step 2: Model Selection").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
623
- Line::from(""),
624
- Line::from(vec![
625
- Span::raw("Model Type: "),
626
- Span::styled(model_type, Style::default().fg(CYBER_CYAN)),
627
- ]),
628
- Line::from(" [ ] Remote (OpenAI, Anthropic, etc.)").style(Style::default().fg(TEXT_DIM)),
629
- Line::from(" [ ] Local (Ollama, LM Studio, etc.)").style(Style::default().fg(TEXT_DIM)),
630
- Line::from(""),
631
- Line::from("Remote Model:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
632
- Line::from(vec![
633
- Span::styled(if focused == 0 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
634
- Span::raw("Provider: "),
635
- Span::styled(&wizard.provider, Style::default().fg(NEON_GREEN)),
636
- Span::styled(if focused == 0 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
637
- ]),
638
- Line::from(vec![
639
- Span::styled(if focused == 1 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
640
- Span::raw("Model: "),
641
- Span::styled(&wizard.model, Style::default().fg(NEON_GREEN)),
642
- Span::styled(if focused == 1 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
643
- ]),
644
- Line::from(""),
645
- Line::from("Local Model:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
646
- Line::from(vec![
647
- Span::styled(if focused == 2 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
648
- Span::raw("Provider: "),
649
- Span::styled(&wizard.local_provider, Style::default().fg(NEON_GREEN)),
650
- Span::styled(if focused == 2 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
651
- ]),
652
- Line::from(vec![
653
- Span::styled(if focused == 3 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
654
- Span::raw("Model: "),
655
- Span::styled(&wizard.local_model, Style::default().fg(NEON_GREEN)),
656
- Span::styled(if focused == 3 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
657
- ]),
658
- Line::from(vec![
659
- Span::styled(if focused == 4 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
660
- Span::raw("URL: "),
661
- Span::styled(&wizard.local_url, Style::default().fg(NEON_GREEN)),
662
- Span::styled(if focused == 4 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
663
- ]),
664
- Line::from(""),
665
- Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
666
- Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
667
- ];
668
-
669
- let paragraph = Paragraph::new(text)
670
- .wrap(Wrap { trim: false })
671
- .alignment(Alignment::Left);
672
-
673
- f.render_widget(paragraph, area);
674
- }
675
-
676
- fn render_step_3_system_prompt(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
677
- let prompt_preview = if wizard.system_prompt.is_empty() {
678
- "(type to add prompt)".to_string()
679
- } else {
680
- wizard.system_prompt.chars().take(200).collect::<String>()
681
- };
682
-
683
- let text = vec![
684
- Line::from(""),
685
- Line::from("Step 3: System Prompt").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
686
- Line::from(""),
687
- Line::from("Define the agent's behavior and personality.").style(Style::default().fg(TEXT_PRIMARY)),
688
- Line::from(""),
689
- Line::from(vec![
690
- Span::styled("▶ ", Style::default().fg(CYBER_CYAN)),
691
- Span::raw("Current Prompt: "),
692
- Span::styled("_", Style::default().fg(CYBER_CYAN)),
693
- ]).style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
694
- Line::from("┌────────────────────────────────────────────────┐").style(Style::default().fg(TEXT_MUTED)),
695
- Line::from(format!("│ {}│", prompt_preview)).style(Style::default().fg(NEON_GREEN)),
696
- Line::from("└────────────────────────────────────────────────┘").style(Style::default().fg(TEXT_MUTED)),
697
- Line::from(""),
698
- Line::from(format!("Characters: {}", wizard.system_prompt.len())).style(Style::default().fg(TEXT_DIM)),
699
- Line::from(""),
700
- Line::from("Examples:").style(Style::default().fg(TEXT_MUTED)),
701
- Line::from(" • You are a helpful coding assistant").style(Style::default().fg(TEXT_DIM)),
702
- Line::from(" • You are an expert data analyst").style(Style::default().fg(TEXT_DIM)),
703
- Line::from(" • You are a creative writing partner").style(Style::default().fg(TEXT_DIM)),
704
- Line::from(""),
705
- Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
706
- Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
707
- ];
708
-
709
- let paragraph = Paragraph::new(text)
710
- .wrap(Wrap { trim: false })
711
- .alignment(Alignment::Left);
712
-
713
- f.render_widget(paragraph, area);
714
- }
715
-
716
- fn render_step_4_parameters(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
717
- let focused = wizard.focused_field;
718
-
719
- let text = vec![
720
- Line::from(""),
721
- Line::from("Step 4: Parameters").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
722
- Line::from(""),
723
- Line::from("Fine-tune the model's behavior.").style(Style::default().fg(TEXT_PRIMARY)),
724
- Line::from(""),
725
- Line::from(vec![
726
- Span::styled(if focused == 0 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
727
- Span::raw("Temperature: "),
728
- Span::styled(format!("{:.2}", wizard.temperature), Style::default().fg(NEON_GREEN)),
729
- Span::styled(if focused == 0 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
730
- Span::raw(" (0.0 = focused, 2.0 = creative)"),
731
- ]).style(Style::default().fg(TEXT_PRIMARY)),
732
- Line::from(""),
733
- Line::from(vec![
734
- Span::styled(if focused == 1 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
735
- Span::raw("Max Tokens: "),
736
- Span::styled(format!("{}", wizard.max_tokens), Style::default().fg(NEON_GREEN)),
737
- Span::styled(if focused == 1 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
738
- Span::raw(" (1-100000)"),
739
- ]).style(Style::default().fg(TEXT_PRIMARY)),
740
- Line::from(""),
741
- Line::from(vec![
742
- Span::raw("Top P: "),
743
- Span::styled(format!("{:.2}", wizard.top_p), Style::default().fg(TEXT_DIM)),
744
- Span::raw(" (0.0-1.0, nucleus sampling)"),
745
- ]).style(Style::default().fg(TEXT_DIM)),
746
- Line::from(""),
747
- Line::from(vec![
748
- Span::raw("Frequency Penalty: "),
749
- Span::styled(format!("{:.2}", wizard.frequency_penalty), Style::default().fg(TEXT_DIM)),
750
- Span::raw(" (-2.0 to 2.0)"),
751
- ]).style(Style::default().fg(TEXT_DIM)),
752
- Line::from(""),
753
- Line::from(vec![
754
- Span::raw("Presence Penalty: "),
755
- Span::styled(format!("{:.2}", wizard.presence_penalty), Style::default().fg(TEXT_DIM)),
756
- Span::raw(" (-2.0 to 2.0)"),
757
- ]).style(Style::default().fg(TEXT_DIM)),
758
- Line::from(""),
759
- Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
760
- Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
761
- ];
762
-
763
- let paragraph = Paragraph::new(text)
764
- .wrap(Wrap { trim: false })
765
- .alignment(Alignment::Left);
766
-
767
- f.render_widget(paragraph, area);
768
- }
769
-
770
- fn render_step_5_tools(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
771
- let selected_count = wizard.selected_tools.len();
772
- let available_count = wizard.available_tools.len();
773
- let focused = wizard.focused_field;
774
-
775
- let mut text = vec![
776
- Line::from(""),
777
- Line::from("Step 5: Tools Selection").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
778
- Line::from(""),
779
- Line::from(format!("Selected: {} / {} tools", selected_count, available_count)).style(Style::default().fg(CYBER_CYAN)),
780
- Line::from(""),
781
- Line::from("Available Tools:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
782
- ];
783
-
784
- for (i, tool) in wizard.available_tools.iter().enumerate() {
785
- let is_selected = wizard.selected_tools.contains(tool);
786
- let checkbox = if is_selected { "[✓]" } else { "[ ]" };
787
- let is_focused = i == focused;
788
- let style = if is_selected {
789
- Style::default().fg(NEON_GREEN)
790
- } else if is_focused {
791
- Style::default().fg(CYBER_CYAN)
792
- } else {
793
- Style::default().fg(TEXT_PRIMARY)
794
- };
795
- text.push(Line::from(vec![
796
- Span::styled(if is_focused { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
797
- Span::styled(format!("{} ", checkbox), style),
798
- Span::styled(tool.clone(), style),
799
- ]));
800
- }
801
-
802
- text.extend(vec![
803
- Line::from(""),
804
- Line::from("Note: Use Space to toggle, ↑↓ to navigate").style(Style::default().fg(TEXT_DIM)),
805
- Line::from(""),
806
- Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
807
- Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
808
- ]);
809
-
810
- let paragraph = Paragraph::new(text)
811
- .wrap(Wrap { trim: false })
812
- .alignment(Alignment::Left);
813
-
814
- f.render_widget(paragraph, area);
815
- }
816
-
817
- fn render_step_6_review(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
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 {
830
- format!("{} ({})", wizard.local_model, wizard.local_provider)
831
- } else {
832
- format!("{} ({})", wizard.model, wizard.provider)
833
- };
834
-
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![
839
- Line::from(""),
840
- Line::from("Step 6: Review & Confirm").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
841
- Line::from(""),
842
- Line::from("✓ Configuration Complete - Ready to Create").style(Style::default().fg(NEON_GREEN).bold()),
843
- Line::from(""),
844
- Line::from("═══════════════════════════════════════════════════").style(Style::default().fg(TEXT_MUTED)),
845
- Line::from(""),
846
- Line::from("Basic Info:").style(Style::default().fg(CYBER_CYAN).bold()),
847
- Line::from(vec![
848
- Span::styled(" Name: ", Style::default().fg(TEXT_DIM)),
849
- Span::styled(&wizard.name, Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
850
- ]),
851
- Line::from(vec![
852
- Span::styled(" Description: ", Style::default().fg(TEXT_DIM)),
853
- Span::styled(
854
- if wizard.description.is_empty() { "(none)" } else { wizard.description.as_str() },
855
- Style::default().fg(TEXT_PRIMARY)
856
- ),
857
- ]),
858
- Line::from(""),
859
- Line::from("Model Configuration:").style(Style::default().fg(CYBER_CYAN).bold()),
860
- Line::from(vec![
861
- Span::styled(" Model: ", Style::default().fg(TEXT_DIM)),
862
- Span::styled(&model_info_str, Style::default().fg(TEXT_PRIMARY).bold()),
863
- ]),
864
- Line::from(vec![
865
- Span::styled(" Context Window: ", Style::default().fg(TEXT_DIM)),
866
- Span::styled(format!("{} tokens", model_info.context_window), Style::default().fg(TEXT_PRIMARY)),
867
- ]),
868
- Line::from(vec![
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)),
875
- ]),
876
- Line::from(""),
877
- Line::from("Tools:").style(Style::default().fg(CYBER_CYAN).bold()),
878
- Line::from(vec![
879
- Span::styled(" ", Style::default()),
880
- Span::styled(
881
- if wizard.selected_tools.is_empty() {
882
- "None selected".to_string()
883
- } else {
884
- wizard.selected_tools.join(", ")
885
- },
886
- Style::default().fg(TEXT_PRIMARY)
887
- ),
888
- ]),
889
- ];
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
-
916
- let paragraph = Paragraph::new(text)
917
- .wrap(Wrap { trim: false })
918
- .alignment(Alignment::Left);
919
-
920
- f.render_widget(paragraph, area);
921
- }
1
+ /// Agent Builder Screen
2
+ /// Multi-step wizard for creating and editing agents
3
+
4
+ use ratatui::prelude::*;
5
+ use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
6
+ use crate::app::AppState;
7
+
8
+ // === 4RUNR BRAND COLORS (matching layout.rs) ===
9
+ const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
+ const 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
+ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
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
+
137
+ /// Agent Builder wizard state
138
+ #[derive(Debug, Clone)]
139
+ pub struct AgentBuilderState {
140
+ pub current_step: usize, // 1-6
141
+ pub focused_field: usize,
142
+
143
+ // Step 1: Basic Info
144
+ pub name: String,
145
+ pub description: String,
146
+
147
+ // Step 2: Model Selection
148
+ pub use_local_model: bool,
149
+ pub provider: String, // "openai", "anthropic", "ollama", etc.
150
+ pub model: String, // "gpt-4", "claude-3", "llama2", etc.
151
+ pub local_provider: String, // "ollama", "lm-studio", "custom"
152
+ pub local_model: String,
153
+ pub local_url: String,
154
+
155
+ // Step 3: System Prompt
156
+ pub system_prompt: String,
157
+
158
+ // Step 4: Parameters
159
+ pub temperature: f32,
160
+ pub max_tokens: u32,
161
+ pub top_p: f32,
162
+ pub frequency_penalty: f32,
163
+ pub presence_penalty: f32,
164
+
165
+ // Step 5: Tools
166
+ pub selected_tools: Vec<String>,
167
+ pub available_tools: Vec<String>,
168
+
169
+ // Validation
170
+ pub validation_errors: Vec<String>,
171
+ }
172
+
173
+ impl Default for AgentBuilderState {
174
+ fn default() -> Self {
175
+ Self {
176
+ current_step: 1,
177
+ focused_field: 0,
178
+ name: String::new(),
179
+ description: String::new(),
180
+ use_local_model: false,
181
+ provider: "openai".to_string(),
182
+ model: "gpt-4".to_string(),
183
+ local_provider: "ollama".to_string(),
184
+ local_model: "llama2".to_string(),
185
+ local_url: "http://localhost:11434".to_string(),
186
+ system_prompt: String::new(),
187
+ temperature: 0.7,
188
+ max_tokens: 2000,
189
+ top_p: 1.0,
190
+ frequency_penalty: 0.0,
191
+ presence_penalty: 0.0,
192
+ selected_tools: Vec::new(),
193
+ available_tools: vec![
194
+ "calculator".to_string(),
195
+ "time".to_string(),
196
+ "weather".to_string(),
197
+ ],
198
+ validation_errors: Vec::new(),
199
+ }
200
+ }
201
+ }
202
+
203
+ impl AgentBuilderState {
204
+ /// Validate current step
205
+ pub fn validate_step(&mut self) -> bool {
206
+ self.validation_errors.clear();
207
+
208
+ match self.current_step {
209
+ 1 => {
210
+ if self.name.trim().is_empty() {
211
+ self.validation_errors.push("Name is required".to_string());
212
+ }
213
+ if self.name.len() > 50 {
214
+ self.validation_errors.push("Name must be 50 characters or less".to_string());
215
+ }
216
+ }
217
+ 2 => {
218
+ if !self.use_local_model && self.model.is_empty() {
219
+ self.validation_errors.push("Model is required".to_string());
220
+ }
221
+ if self.use_local_model && self.local_model.is_empty() {
222
+ self.validation_errors.push("Local model is required".to_string());
223
+ }
224
+ }
225
+ 3 => {
226
+ // System prompt is optional
227
+ }
228
+ 4 => {
229
+ if !(0.0..=2.0).contains(&self.temperature) {
230
+ self.validation_errors.push("Temperature must be between 0.0 and 2.0".to_string());
231
+ }
232
+ if self.max_tokens < 1 || self.max_tokens > 100000 {
233
+ self.validation_errors.push("Max tokens must be between 1 and 100000".to_string());
234
+ }
235
+ }
236
+ 5 => {
237
+ // Tools are optional
238
+ }
239
+ 6 => {
240
+ // Review step - validate all
241
+ if self.name.trim().is_empty() {
242
+ self.validation_errors.push("Name is required".to_string());
243
+ }
244
+ }
245
+ _ => {}
246
+ }
247
+
248
+ self.validation_errors.is_empty()
249
+ }
250
+
251
+ /// Move to next step
252
+ pub fn next_step(&mut self) -> bool {
253
+ if self.validate_step() && self.current_step < 6 {
254
+ self.current_step += 1;
255
+ self.focused_field = 0;
256
+ true
257
+ } else {
258
+ false
259
+ }
260
+ }
261
+
262
+ /// Move to previous step
263
+ pub fn prev_step(&mut self) {
264
+ if self.current_step > 1 {
265
+ self.current_step -= 1;
266
+ self.focused_field = 0;
267
+ }
268
+ }
269
+
270
+ /// Move to next field within current step
271
+ pub fn next_field(&mut self) {
272
+ let max_field = self.get_max_field_for_step();
273
+ if self.focused_field < max_field {
274
+ self.focused_field += 1;
275
+ }
276
+ }
277
+
278
+ /// Move to previous field within current step
279
+ pub fn prev_field(&mut self) {
280
+ if self.focused_field > 0 {
281
+ self.focused_field -= 1;
282
+ }
283
+ }
284
+
285
+ /// Get the maximum field index for the current step
286
+ fn get_max_field_for_step(&self) -> usize {
287
+ match self.current_step {
288
+ 1 => 1, // Name, Description
289
+ 2 => 4, // Provider, Model, Local Provider, Local Model, Local URL
290
+ 3 => 0, // System Prompt (single field)
291
+ 4 => 4, // Temperature, Max Tokens, Top P, Frequency Penalty, Presence Penalty
292
+ 5 => self.available_tools.len().saturating_sub(1), // Tools list
293
+ 6 => 0, // Review (no editable fields)
294
+ _ => 0,
295
+ }
296
+ }
297
+ }
298
+
299
+ /// Render the Agent Builder screen
300
+ pub fn render(f: &mut Frame, state: &AppState) {
301
+ let area = f.size();
302
+
303
+ // Get wizard state from AppState
304
+ let wizard_state = &state.agent_builder;
305
+
306
+ render_wizard(f, area, wizard_state);
307
+ }
308
+
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
+
344
+ // Main container with brand styling
345
+ let block = Block::default()
346
+ .title(format!(" 🤖 Agent Builder - Step {}/6 {} ", wizard.current_step, progress_bar))
347
+ .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
348
+ .borders(Borders::ALL)
349
+ .border_style(Style::default().fg(BRAND_PURPLE))
350
+ .style(Style::default().bg(BG_PANEL));
351
+
352
+ let inner = block.inner(h_chunks[0]);
353
+ f.render_widget(block, h_chunks[0]);
354
+
355
+ // Render current step
356
+ match wizard.current_step {
357
+ 1 => render_step_1_basic_info(f, inner, wizard),
358
+ 2 => render_step_2_model_selection(f, inner, wizard),
359
+ 3 => render_step_3_system_prompt(f, inner, wizard),
360
+ 4 => render_step_4_parameters(f, inner, wizard),
361
+ 5 => render_step_5_tools(f, inner, wizard),
362
+ 6 => render_step_6_review(f, inner, wizard),
363
+ _ => {}
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);
567
+ }
568
+
569
+ fn render_step_1_basic_info(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
570
+ let focused = wizard.focused_field;
571
+
572
+ let text = vec![
573
+ Line::from(""),
574
+ Line::from("Step 1: Basic Information").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
575
+ Line::from(""),
576
+ Line::from(vec![
577
+ Span::styled(if focused == 0 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
578
+ Span::raw("Name: "),
579
+ Span::styled(&wizard.name, Style::default().fg(NEON_GREEN)),
580
+ Span::styled(if focused == 0 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
581
+ ]),
582
+ Line::from(" (Required, max 50 characters)").style(Style::default().fg(TEXT_DIM)),
583
+ Line::from(""),
584
+ Line::from(vec![
585
+ Span::styled(if focused == 1 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
586
+ Span::raw("Description: "),
587
+ Span::styled(&wizard.description, Style::default().fg(NEON_GREEN)),
588
+ Span::styled(if focused == 1 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
589
+ ]),
590
+ Line::from(" (Optional, brief description of agent purpose)").style(Style::default().fg(TEXT_DIM)),
591
+ Line::from(""),
592
+ Line::from(""),
593
+ Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
594
+ Line::from(" Tab/Shift+Tab/↑↓ - Move between fields").style(Style::default().fg(TEXT_DIM)),
595
+ Line::from(" Enter - Next step").style(Style::default().fg(TEXT_DIM)),
596
+ Line::from(" ESC - Cancel and return to Main").style(Style::default().fg(TEXT_DIM)),
597
+ ];
598
+
599
+ // Show validation errors if any
600
+ let mut all_lines = text;
601
+ if !wizard.validation_errors.is_empty() {
602
+ all_lines.push(Line::from(""));
603
+ all_lines.push(Line::from("Validation Errors:").style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)));
604
+ for error in &wizard.validation_errors {
605
+ all_lines.push(Line::from(format!(" • {}", error)).style(Style::default().fg(Color::Red)));
606
+ }
607
+ }
608
+
609
+ let paragraph = Paragraph::new(all_lines)
610
+ .wrap(Wrap { trim: false })
611
+ .alignment(Alignment::Left);
612
+
613
+ f.render_widget(paragraph, area);
614
+ }
615
+
616
+ fn render_step_2_model_selection(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
617
+ let focused = wizard.focused_field;
618
+ let model_type = if wizard.use_local_model { "Local" } else { "Remote" };
619
+
620
+ let text = vec![
621
+ Line::from(""),
622
+ Line::from("Step 2: Model Selection").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
623
+ Line::from(""),
624
+ Line::from(vec![
625
+ Span::raw("Model Type: "),
626
+ Span::styled(model_type, Style::default().fg(CYBER_CYAN)),
627
+ ]),
628
+ Line::from(" [ ] Remote (OpenAI, Anthropic, etc.)").style(Style::default().fg(TEXT_DIM)),
629
+ Line::from(" [ ] Local (Ollama, LM Studio, etc.)").style(Style::default().fg(TEXT_DIM)),
630
+ Line::from(""),
631
+ Line::from("Remote Model:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
632
+ Line::from(vec![
633
+ Span::styled(if focused == 0 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
634
+ Span::raw("Provider: "),
635
+ Span::styled(&wizard.provider, Style::default().fg(NEON_GREEN)),
636
+ Span::styled(if focused == 0 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
637
+ ]),
638
+ Line::from(vec![
639
+ Span::styled(if focused == 1 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
640
+ Span::raw("Model: "),
641
+ Span::styled(&wizard.model, Style::default().fg(NEON_GREEN)),
642
+ Span::styled(if focused == 1 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
643
+ ]),
644
+ Line::from(""),
645
+ Line::from("Local Model:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
646
+ Line::from(vec![
647
+ Span::styled(if focused == 2 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
648
+ Span::raw("Provider: "),
649
+ Span::styled(&wizard.local_provider, Style::default().fg(NEON_GREEN)),
650
+ Span::styled(if focused == 2 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
651
+ ]),
652
+ Line::from(vec![
653
+ Span::styled(if focused == 3 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
654
+ Span::raw("Model: "),
655
+ Span::styled(&wizard.local_model, Style::default().fg(NEON_GREEN)),
656
+ Span::styled(if focused == 3 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
657
+ ]),
658
+ Line::from(vec![
659
+ Span::styled(if focused == 4 { " ▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
660
+ Span::raw("URL: "),
661
+ Span::styled(&wizard.local_url, Style::default().fg(NEON_GREEN)),
662
+ Span::styled(if focused == 4 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
663
+ ]),
664
+ Line::from(""),
665
+ Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
666
+ Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
667
+ ];
668
+
669
+ let paragraph = Paragraph::new(text)
670
+ .wrap(Wrap { trim: false })
671
+ .alignment(Alignment::Left);
672
+
673
+ f.render_widget(paragraph, area);
674
+ }
675
+
676
+ fn render_step_3_system_prompt(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
677
+ let prompt_preview = if wizard.system_prompt.is_empty() {
678
+ "(type to add prompt)".to_string()
679
+ } else {
680
+ wizard.system_prompt.chars().take(200).collect::<String>()
681
+ };
682
+
683
+ let text = vec![
684
+ Line::from(""),
685
+ Line::from("Step 3: System Prompt").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
686
+ Line::from(""),
687
+ Line::from("Define the agent's behavior and personality.").style(Style::default().fg(TEXT_PRIMARY)),
688
+ Line::from(""),
689
+ Line::from(vec![
690
+ Span::styled("▶ ", Style::default().fg(CYBER_CYAN)),
691
+ Span::raw("Current Prompt: "),
692
+ Span::styled("_", Style::default().fg(CYBER_CYAN)),
693
+ ]).style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
694
+ Line::from("┌────────────────────────────────────────────────┐").style(Style::default().fg(TEXT_MUTED)),
695
+ Line::from(format!("│ {}│", prompt_preview)).style(Style::default().fg(NEON_GREEN)),
696
+ Line::from("└────────────────────────────────────────────────┘").style(Style::default().fg(TEXT_MUTED)),
697
+ Line::from(""),
698
+ Line::from(format!("Characters: {}", wizard.system_prompt.len())).style(Style::default().fg(TEXT_DIM)),
699
+ Line::from(""),
700
+ Line::from("Examples:").style(Style::default().fg(TEXT_MUTED)),
701
+ Line::from(" • You are a helpful coding assistant").style(Style::default().fg(TEXT_DIM)),
702
+ Line::from(" • You are an expert data analyst").style(Style::default().fg(TEXT_DIM)),
703
+ Line::from(" • You are a creative writing partner").style(Style::default().fg(TEXT_DIM)),
704
+ Line::from(""),
705
+ Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
706
+ Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
707
+ ];
708
+
709
+ let paragraph = Paragraph::new(text)
710
+ .wrap(Wrap { trim: false })
711
+ .alignment(Alignment::Left);
712
+
713
+ f.render_widget(paragraph, area);
714
+ }
715
+
716
+ fn render_step_4_parameters(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
717
+ let focused = wizard.focused_field;
718
+
719
+ let text = vec![
720
+ Line::from(""),
721
+ Line::from("Step 4: Parameters").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
722
+ Line::from(""),
723
+ Line::from("Fine-tune the model's behavior.").style(Style::default().fg(TEXT_PRIMARY)),
724
+ Line::from(""),
725
+ Line::from(vec![
726
+ Span::styled(if focused == 0 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
727
+ Span::raw("Temperature: "),
728
+ Span::styled(format!("{:.2}", wizard.temperature), Style::default().fg(NEON_GREEN)),
729
+ Span::styled(if focused == 0 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
730
+ Span::raw(" (0.0 = focused, 2.0 = creative)"),
731
+ ]).style(Style::default().fg(TEXT_PRIMARY)),
732
+ Line::from(""),
733
+ Line::from(vec![
734
+ Span::styled(if focused == 1 { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
735
+ Span::raw("Max Tokens: "),
736
+ Span::styled(format!("{}", wizard.max_tokens), Style::default().fg(NEON_GREEN)),
737
+ Span::styled(if focused == 1 { "_" } else { "" }, Style::default().fg(CYBER_CYAN)),
738
+ Span::raw(" (1-100000)"),
739
+ ]).style(Style::default().fg(TEXT_PRIMARY)),
740
+ Line::from(""),
741
+ Line::from(vec![
742
+ Span::raw("Top P: "),
743
+ Span::styled(format!("{:.2}", wizard.top_p), Style::default().fg(TEXT_DIM)),
744
+ Span::raw(" (0.0-1.0, nucleus sampling)"),
745
+ ]).style(Style::default().fg(TEXT_DIM)),
746
+ Line::from(""),
747
+ Line::from(vec![
748
+ Span::raw("Frequency Penalty: "),
749
+ Span::styled(format!("{:.2}", wizard.frequency_penalty), Style::default().fg(TEXT_DIM)),
750
+ Span::raw(" (-2.0 to 2.0)"),
751
+ ]).style(Style::default().fg(TEXT_DIM)),
752
+ Line::from(""),
753
+ Line::from(vec![
754
+ Span::raw("Presence Penalty: "),
755
+ Span::styled(format!("{:.2}", wizard.presence_penalty), Style::default().fg(TEXT_DIM)),
756
+ Span::raw(" (-2.0 to 2.0)"),
757
+ ]).style(Style::default().fg(TEXT_DIM)),
758
+ Line::from(""),
759
+ Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
760
+ Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
761
+ ];
762
+
763
+ let paragraph = Paragraph::new(text)
764
+ .wrap(Wrap { trim: false })
765
+ .alignment(Alignment::Left);
766
+
767
+ f.render_widget(paragraph, area);
768
+ }
769
+
770
+ fn render_step_5_tools(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
771
+ let selected_count = wizard.selected_tools.len();
772
+ let available_count = wizard.available_tools.len();
773
+ let focused = wizard.focused_field;
774
+
775
+ let mut text = vec![
776
+ Line::from(""),
777
+ Line::from("Step 5: Tools Selection").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
778
+ Line::from(""),
779
+ Line::from(format!("Selected: {} / {} tools", selected_count, available_count)).style(Style::default().fg(CYBER_CYAN)),
780
+ Line::from(""),
781
+ Line::from("Available Tools:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
782
+ ];
783
+
784
+ for (i, tool) in wizard.available_tools.iter().enumerate() {
785
+ let is_selected = wizard.selected_tools.contains(tool);
786
+ let checkbox = if is_selected { "[✓]" } else { "[ ]" };
787
+ let is_focused = i == focused;
788
+ let style = if is_selected {
789
+ Style::default().fg(NEON_GREEN)
790
+ } else if is_focused {
791
+ Style::default().fg(CYBER_CYAN)
792
+ } else {
793
+ Style::default().fg(TEXT_PRIMARY)
794
+ };
795
+ text.push(Line::from(vec![
796
+ Span::styled(if is_focused { "▶ " } else { " " }, Style::default().fg(CYBER_CYAN)),
797
+ Span::styled(format!("{} ", checkbox), style),
798
+ Span::styled(tool.clone(), style),
799
+ ]));
800
+ }
801
+
802
+ text.extend(vec![
803
+ Line::from(""),
804
+ Line::from("Note: Use Space to toggle, ↑↓ to navigate").style(Style::default().fg(TEXT_DIM)),
805
+ Line::from(""),
806
+ Line::from("Navigation:").style(Style::default().fg(TEXT_MUTED)),
807
+ Line::from(" Enter - Next step | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
808
+ ]);
809
+
810
+ let paragraph = Paragraph::new(text)
811
+ .wrap(Wrap { trim: false })
812
+ .alignment(Alignment::Left);
813
+
814
+ f.render_widget(paragraph, area);
815
+ }
816
+
817
+ fn render_step_6_review(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
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 {
830
+ format!("{} ({})", wizard.local_model, wizard.local_provider)
831
+ } else {
832
+ format!("{} ({})", wizard.model, wizard.provider)
833
+ };
834
+
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![
839
+ Line::from(""),
840
+ Line::from("Step 6: Review & Confirm").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
841
+ Line::from(""),
842
+ Line::from("✓ Configuration Complete - Ready to Create").style(Style::default().fg(NEON_GREEN).bold()),
843
+ Line::from(""),
844
+ Line::from("═══════════════════════════════════════════════════").style(Style::default().fg(TEXT_MUTED)),
845
+ Line::from(""),
846
+ Line::from("Basic Info:").style(Style::default().fg(CYBER_CYAN).bold()),
847
+ Line::from(vec![
848
+ Span::styled(" Name: ", Style::default().fg(TEXT_DIM)),
849
+ Span::styled(&wizard.name, Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
850
+ ]),
851
+ Line::from(vec![
852
+ Span::styled(" Description: ", Style::default().fg(TEXT_DIM)),
853
+ Span::styled(
854
+ if wizard.description.is_empty() { "(none)" } else { wizard.description.as_str() },
855
+ Style::default().fg(TEXT_PRIMARY)
856
+ ),
857
+ ]),
858
+ Line::from(""),
859
+ Line::from("Model Configuration:").style(Style::default().fg(CYBER_CYAN).bold()),
860
+ Line::from(vec![
861
+ Span::styled(" Model: ", Style::default().fg(TEXT_DIM)),
862
+ Span::styled(&model_info_str, Style::default().fg(TEXT_PRIMARY).bold()),
863
+ ]),
864
+ Line::from(vec![
865
+ Span::styled(" Context Window: ", Style::default().fg(TEXT_DIM)),
866
+ Span::styled(format!("{} tokens", model_info.context_window), Style::default().fg(TEXT_PRIMARY)),
867
+ ]),
868
+ Line::from(vec![
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)),
875
+ ]),
876
+ Line::from(""),
877
+ Line::from("Tools:").style(Style::default().fg(CYBER_CYAN).bold()),
878
+ Line::from(vec![
879
+ Span::styled(" ", Style::default()),
880
+ Span::styled(
881
+ if wizard.selected_tools.is_empty() {
882
+ "None selected".to_string()
883
+ } else {
884
+ wizard.selected_tools.join(", ")
885
+ },
886
+ Style::default().fg(TEXT_PRIMARY)
887
+ ),
888
+ ]),
889
+ ];
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
+
916
+ let paragraph = Paragraph::new(text)
917
+ .wrap(Wrap { trim: false })
918
+ .alignment(Alignment::Left);
919
+
920
+ f.render_widget(paragraph, area);
921
+ }