4runr-os 2.4.1 → 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.
@@ -29,6 +29,35 @@ pub enum AppMode {
29
29
  Main,
30
30
  }
31
31
 
32
+ #[derive(Debug, Clone)]
33
+ pub struct AgentListState {
34
+ pub agents: Vec<AgentInfo>,
35
+ pub selected_index: usize,
36
+ pub detail_view: Option<usize>, // None = list view, Some(index) = detail popup
37
+ }
38
+
39
+ impl Default for AgentListState {
40
+ fn default() -> Self {
41
+ Self {
42
+ agents: Vec::new(),
43
+ selected_index: 0,
44
+ detail_view: None,
45
+ }
46
+ }
47
+ }
48
+
49
+ #[derive(Debug, Clone)]
50
+ pub struct AgentInfo {
51
+ pub name: String,
52
+ pub description: Option<String>,
53
+ pub model: String,
54
+ pub provider: String,
55
+ pub system_prompt: Option<String>,
56
+ pub temperature: Option<f32>,
57
+ pub max_tokens: Option<u32>,
58
+ pub tools: Vec<String>,
59
+ }
60
+
32
61
  #[derive(Debug, Clone)]
33
62
  pub struct AppState {
34
63
  // Navigation state (NEW - replaces simple mode)
@@ -96,6 +125,7 @@ pub struct AppState {
96
125
  pub agent_builder: AgentBuilderState,
97
126
  pub run_manager: RunManagerState,
98
127
  pub settings: SettingsState,
128
+ pub agent_list: AgentListState,
99
129
 
100
130
  // Local cache
101
131
  pub cache: Option<Cache>,
@@ -153,6 +183,7 @@ impl Default for AppState {
153
183
  agent_builder: AgentBuilderState::default(),
154
184
  run_manager: RunManagerState::default(),
155
185
  settings: SettingsState::default(),
186
+ agent_list: AgentListState::default(),
156
187
  cache: Cache::new().ok(),
157
188
  cache_loaded: false,
158
189
  }
@@ -339,6 +370,11 @@ impl App {
339
370
  return self.handle_settings_input(key);
340
371
  }
341
372
 
373
+ // === AGENT LIST INPUT HANDLING ===
374
+ if self.state.navigation.current_screen() == &Screen::AgentList {
375
+ return self.handle_agent_list_input(key);
376
+ }
377
+
342
378
  // === MAIN INPUT HANDLING ===
343
379
  match key.code {
344
380
  // Typing - ALL characters go to command input (with debounce)
@@ -362,48 +398,9 @@ impl App {
362
398
  "quit" | "exit" => return Ok(true),
363
399
  "clear" => self.state.logs.clear(),
364
400
  "help" => {
365
- self.state.logs.push_back("[HELP] ═══════════════════════════════════════".into());
366
- self.state.logs.push_back("[HELP] 4Runr AI Agent OS - Command Reference".into());
367
- self.state.logs.push_back("[HELP] ═══════════════════════════════════════".into());
368
- self.state.logs.push_back("".into());
369
-
370
- // Local Commands
371
- self.state.logs.push_back("[HELP] Local Commands:".into());
372
- self.state.logs.push_back(" quit, exit - Exit application".into());
373
- self.state.logs.push_back(" clear - Clear logs".into());
374
- self.state.logs.push_back(" help - Show this help".into());
375
- self.state.logs.push_back(" :perf - Show performance stats".into());
376
- self.state.logs.push_back("".into());
377
-
378
- // Navigation Commands
379
- self.state.logs.push_back("[HELP] Navigation Commands:".into());
380
- self.state.logs.push_back(" build - Open Agent Builder (6-step wizard)".into());
381
- self.state.logs.push_back(" runs - Open Run Manager (list, filter, sort)".into());
382
- self.state.logs.push_back(" config, settings - Open Settings (mode, AI provider)".into());
383
- self.state.logs.push_back(" ESC - Close overlay/popup or clear input".into());
384
- self.state.logs.push_back("".into());
385
-
386
- // Screen Controls
387
- self.state.logs.push_back("[HELP] Screen Controls:".into());
388
- self.state.logs.push_back(" Agent Builder: Enter=Next, Backspace=Prev, ESC=Cancel".into());
389
- self.state.logs.push_back(" Run Manager: ↑/↓=Navigate, F=Filter, S=Sort, R=Refresh".into());
390
- self.state.logs.push_back(" Settings: ↑/↓=Navigate, Space=Toggle, Enter=Save".into());
391
- self.state.logs.push_back("".into());
392
-
393
- // WebSocket Commands - Label BEFORE separator
394
- self.state.logs.push_back("[HELP] WebSocket Commands (requires connection):".into());
395
- self.state.logs.push_back("[HELP] ─────────────────────────────────────────".into());
396
- self.state.logs.push_back(" agent.list - List all agents".into());
397
- self.state.logs.push_back(" agent.get - Get agent details (data: {name})".into());
398
- self.state.logs.push_back(" agent.create - Create agent (use Agent Builder)".into());
399
- self.state.logs.push_back(" agent.delete - Delete agent (data: {name})".into());
400
- self.state.logs.push_back(" system.status - Get system status".into());
401
- self.state.logs.push_back(" run.list - List runs (requires gateway)".into());
402
- self.state.logs.push_back(" tool.list - List available tools".into());
403
- self.state.logs.push_back("".into());
404
-
405
- self.state.logs.push_back("[HELP] Press F12 to toggle performance overlay".into());
406
- self.state.logs.push_back("[HELP] ═══════════════════════════════════════".into());
401
+ // Open help popup instead of logging to operations log
402
+ self.push_popup(Screen::Help);
403
+ self.request_render("help_command");
407
404
  }
408
405
  ":perf" => {
409
406
  // Perf self-check command
@@ -591,6 +588,10 @@ impl App {
591
588
  use crate::ui::settings;
592
589
  settings::render(f, &self.state);
593
590
  }
591
+ Screen::AgentList => {
592
+ use crate::ui::agent_list;
593
+ agent_list::render(f, &self.state);
594
+ }
594
595
  Screen::Confirmation { message, action } => {
595
596
  // TODO: Implement confirmation popup
596
597
  // For now, just render the base screen
@@ -606,10 +607,13 @@ impl App {
606
607
  let _ = message; // Suppress warning
607
608
  }
608
609
  Screen::Help => {
609
- // TODO: Implement help popup
610
- // For now, just render the base screen
610
+ // Render base screen first (dimmed)
611
611
  use crate::ui::layout;
612
612
  layout::render(f, &self.state);
613
+
614
+ // Render help popup overlay
615
+ use crate::ui::help;
616
+ help::render_help(f, f.size(), &self.state);
613
617
  }
614
618
  }
615
619
  }
@@ -1108,6 +1112,69 @@ impl App {
1108
1112
  Ok(false)
1109
1113
  }
1110
1114
 
1115
+ // ============================================================
1116
+ // AGENT LIST INPUT HANDLING (Step 4)
1117
+ // ============================================================
1118
+
1119
+ /// Handle input when Agent List screen is active
1120
+ fn handle_agent_list_input(&mut self, key: KeyEvent) -> anyhow::Result<bool> {
1121
+ use crossterm::event::KeyModifiers;
1122
+
1123
+ // Ctrl+C/Q to exit
1124
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
1125
+ match key.code {
1126
+ KeyCode::Char('c') | KeyCode::Char('q') => return Ok(true),
1127
+ _ => return Ok(false),
1128
+ }
1129
+ }
1130
+
1131
+ match key.code {
1132
+ // Navigation
1133
+ KeyCode::Up => {
1134
+ if self.state.agent_list.selected_index > 0 {
1135
+ self.state.agent_list.selected_index -= 1;
1136
+ }
1137
+ self.request_render("agent_list_up");
1138
+ }
1139
+ KeyCode::Down => {
1140
+ let max = self.state.agent_list.agents.len().saturating_sub(1);
1141
+ if self.state.agent_list.selected_index < max {
1142
+ self.state.agent_list.selected_index += 1;
1143
+ }
1144
+ self.request_render("agent_list_down");
1145
+ }
1146
+
1147
+ // View details / Close detail view
1148
+ KeyCode::Enter => {
1149
+ if self.state.agent_list.detail_view.is_some() {
1150
+ // Close detail view
1151
+ self.state.agent_list.detail_view = None;
1152
+ } else {
1153
+ // Open detail view for selected agent
1154
+ self.state.agent_list.detail_view = Some(self.state.agent_list.selected_index);
1155
+ }
1156
+ self.request_render("agent_list_toggle_detail");
1157
+ }
1158
+
1159
+ // Close
1160
+ KeyCode::Esc => {
1161
+ if self.state.agent_list.detail_view.is_some() {
1162
+ // Close detail popup first
1163
+ self.state.agent_list.detail_view = None;
1164
+ self.request_render("agent_list_close_detail");
1165
+ } else {
1166
+ // Close agent list
1167
+ self.pop_overlay();
1168
+ self.request_render("agent_list_close");
1169
+ }
1170
+ }
1171
+
1172
+ _ => {}
1173
+ }
1174
+
1175
+ Ok(false)
1176
+ }
1177
+
1111
1178
  // ============================================================
1112
1179
  // SETTINGS INPUT HANDLING (Step 4.8)
1113
1180
  // ============================================================
@@ -151,12 +151,35 @@ fn main() -> Result<()> {
151
151
  })
152
152
  .collect();
153
153
 
154
+ // Parse agents into AgentInfo structs for Agent List viewer
155
+ use crate::app::AgentInfo;
156
+ let agents: Vec<AgentInfo> = agents_array.iter()
157
+ .filter_map(|agent| {
158
+ let obj = agent.as_object()?;
159
+ Some(AgentInfo {
160
+ name: obj.get("name")?.as_str()?.to_string(),
161
+ description: obj.get("description").and_then(|d| d.as_str()).map(|s| s.to_string()),
162
+ model: obj.get("model").and_then(|m| m.as_str()).unwrap_or("unknown").to_string(),
163
+ provider: obj.get("provider").and_then(|p| p.as_str()).unwrap_or("unknown").to_string(),
164
+ system_prompt: obj.get("systemPrompt").and_then(|sp| sp.as_str()).map(|s| s.to_string()),
165
+ temperature: obj.get("temperature").and_then(|t| t.as_f64()).map(|f| f as f32),
166
+ max_tokens: obj.get("maxTokens").and_then(|mt| mt.as_u64()).map(|u| u as u32),
167
+ tools: obj.get("tools")
168
+ .and_then(|t| t.as_array())
169
+ .map(|arr| arr.iter()
170
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
171
+ .collect())
172
+ .unwrap_or_default(),
173
+ })
174
+ })
175
+ .collect();
176
+
154
177
  // Update cache with agent data
155
178
  if let Some(cache) = &mut app.state.cache {
156
179
  use crate::storage::cache::AgentData;
157
180
  use std::time::{SystemTime, UNIX_EPOCH};
158
181
 
159
- let agents: Vec<AgentData> = agents_array.iter()
182
+ let cache_agents: Vec<AgentData> = agents_array.iter()
160
183
  .filter_map(|agent| {
161
184
  let obj = agent.as_object()?;
162
185
  Some(AgentData {
@@ -169,9 +192,18 @@ fn main() -> Result<()> {
169
192
  })
170
193
  .collect();
171
194
 
172
- let _ = cache.update_agents(agents);
195
+ let _ = cache.update_agents(cache_agents);
173
196
  }
174
197
 
198
+ // Update agent list state
199
+ app.state.agent_list.agents = agents;
200
+ app.state.agent_list.selected_index = 0;
201
+ app.state.agent_list.detail_view = None;
202
+
203
+ // Open AgentList overlay
204
+ app.push_overlay(Screen::AgentList);
205
+ app.request_render("agent_list_opened");
206
+
175
207
  // Only log if agents were actually loaded
176
208
  if agents_array.len() > 0 {
177
209
  app.add_log(format!(
@@ -19,6 +19,7 @@ pub enum Screen {
19
19
  AgentBuilder,
20
20
  RunManager,
21
21
  Settings,
22
+ AgentList,
22
23
 
23
24
  // Popup screens (small overlays)
24
25
  Confirmation { message: String, action: String },
@@ -31,7 +32,7 @@ impl Screen {
31
32
  pub fn is_overlay(&self) -> bool {
32
33
  matches!(
33
34
  self,
34
- Screen::AgentBuilder | Screen::RunManager | Screen::Settings
35
+ Screen::AgentBuilder | Screen::RunManager | Screen::Settings | Screen::AgentList
35
36
  )
36
37
  }
37
38
 
@@ -56,6 +57,7 @@ impl Screen {
56
57
  Screen::AgentBuilder => "Agent Builder",
57
58
  Screen::RunManager => "Run Manager",
58
59
  Screen::Settings => "Settings",
60
+ Screen::AgentList => "Agent List",
59
61
  Screen::Confirmation { .. } => "Confirmation",
60
62
  Screen::Alert { .. } => "Alert",
61
63
  Screen::Help => "Help",
@@ -0,0 +1,247 @@
1
+ /// Agent List Screen
2
+ /// View and manage AI agents
3
+
4
+ use ratatui::prelude::*;
5
+ use ratatui::widgets::{Block, Borders, Paragraph, Wrap, Table, Row, Cell};
6
+ use crate::app::{AppState, AgentInfo};
7
+
8
+ // === 4RUNR BRAND COLORS ===
9
+ const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
+ const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
11
+ const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
12
+ const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
13
+ const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
14
+ const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
15
+ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
16
+
17
+ /// Render agent list screen
18
+ pub fn render(f: &mut Frame, state: &AppState) {
19
+ let area = f.size();
20
+
21
+ if state.agent_list.detail_view.is_some() {
22
+ // Render list view first (as base)
23
+ render_list_view(f, area, state);
24
+ // Then render detail popup on top
25
+ render_detail_popup(f, area, state);
26
+ } else {
27
+ // Render list view only
28
+ render_list_view(f, area, state);
29
+ }
30
+ }
31
+
32
+ fn render_list_view(f: &mut Frame, area: Rect, state: &AppState) {
33
+ use ratatui::layout::{Constraint, Direction, Layout};
34
+
35
+ // Split into header, content, footer
36
+ let chunks = Layout::default()
37
+ .direction(Direction::Vertical)
38
+ .constraints([
39
+ Constraint::Length(3), // Header
40
+ Constraint::Min(0), // Content
41
+ Constraint::Length(3), // Footer
42
+ ])
43
+ .split(area);
44
+
45
+ // === HEADER ===
46
+ let agent_count = state.agent_list.agents.len();
47
+ let header_block = Block::default()
48
+ .title(format!(" 🤖 Agent List ({} agents) ", agent_count))
49
+ .title_style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))
50
+ .borders(Borders::ALL)
51
+ .border_style(Style::default().fg(CYBER_CYAN))
52
+ .style(Style::default().bg(BG_PANEL));
53
+
54
+ f.render_widget(header_block, chunks[0]);
55
+
56
+ // === CONTENT: Agent Table ===
57
+ let content_block = Block::default()
58
+ .borders(Borders::ALL)
59
+ .border_style(Style::default().fg(TEXT_MUTED))
60
+ .style(Style::default().bg(BG_PANEL));
61
+
62
+ f.render_widget(content_block.clone(), chunks[1]);
63
+
64
+ let table_area = content_block.inner(chunks[1]);
65
+
66
+ // Create table rows
67
+ let rows: Vec<Row> = state.agent_list.agents.iter().enumerate().map(|(i, agent)| {
68
+ let is_selected = i == state.agent_list.selected_index;
69
+ let style = if is_selected {
70
+ Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)
71
+ } else {
72
+ Style::default().fg(TEXT_PRIMARY)
73
+ };
74
+
75
+ let name = if is_selected {
76
+ format!("▶ {}", agent.name)
77
+ } else {
78
+ format!(" {}", agent.name)
79
+ };
80
+
81
+ let desc = agent.description.as_deref().unwrap_or("No description")
82
+ .chars().take(30).collect::<String>();
83
+ let model = agent.model.chars().take(15).collect::<String>();
84
+ let provider = agent.provider.chars().take(10).collect::<String>();
85
+
86
+ Row::new(vec![
87
+ Cell::from(name).style(style),
88
+ Cell::from(desc).style(style),
89
+ Cell::from(model).style(style),
90
+ Cell::from(provider).style(style),
91
+ ])
92
+ }).collect();
93
+
94
+ let table = Table::new(
95
+ rows,
96
+ [
97
+ Constraint::Percentage(25),
98
+ Constraint::Percentage(35),
99
+ Constraint::Percentage(20),
100
+ Constraint::Percentage(20),
101
+ ]
102
+ )
103
+ .header(Row::new(vec![
104
+ Cell::from("Name").style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
105
+ Cell::from("Description").style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
106
+ Cell::from("Model").style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
107
+ Cell::from("Provider").style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
108
+ ]))
109
+ .column_spacing(1)
110
+ .style(Style::default().fg(TEXT_PRIMARY));
111
+
112
+ f.render_widget(table, table_area);
113
+
114
+ // === FOOTER ===
115
+ let footer_text = Line::from(vec![
116
+ Span::styled("↑/↓", Style::default().fg(CYBER_CYAN)),
117
+ Span::styled(" Navigate | ", Style::default().fg(TEXT_DIM)),
118
+ Span::styled("Enter", Style::default().fg(NEON_GREEN)),
119
+ Span::styled(" View Details | ", Style::default().fg(TEXT_DIM)),
120
+ Span::styled("ESC", Style::default().fg(BRAND_PURPLE)),
121
+ Span::styled(" Close", Style::default().fg(TEXT_DIM)),
122
+ ]);
123
+
124
+ f.render_widget(
125
+ Paragraph::new(footer_text)
126
+ .alignment(Alignment::Center)
127
+ .style(Style::default().bg(BG_PANEL)),
128
+ chunks[2]
129
+ );
130
+ }
131
+
132
+ fn render_detail_popup(f: &mut Frame, area: Rect, state: &AppState) {
133
+ let detail_index = state.agent_list.detail_view.unwrap();
134
+ if let Some(agent) = state.agent_list.agents.get(detail_index) {
135
+ render_agent_detail(f, area, agent);
136
+ }
137
+ }
138
+
139
+ fn render_agent_detail(f: &mut Frame, area: Rect, agent: &AgentInfo) {
140
+ use ratatui::layout::{Constraint, Direction, Layout};
141
+
142
+ // Calculate popup size (70% width, 80% height, centered)
143
+ let popup_width = (area.width * 70 / 100).max(50);
144
+ let popup_height = (area.height * 80 / 100).max(15);
145
+ let popup_x = (area.width.saturating_sub(popup_width)) / 2;
146
+ let popup_y = (area.height.saturating_sub(popup_height)) / 2;
147
+
148
+ let popup_area = Rect {
149
+ x: popup_x,
150
+ y: popup_y,
151
+ width: popup_width,
152
+ height: popup_height,
153
+ };
154
+
155
+ // Render dimmed overlay
156
+ let overlay = Block::default()
157
+ .style(Style::default().bg(Color::Black).fg(Color::Black));
158
+ f.render_widget(overlay, area);
159
+
160
+ // Render detail popup
161
+ let detail_block = Block::default()
162
+ .title(format!(" 📋 Agent Details: {} ", agent.name))
163
+ .title_style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))
164
+ .borders(Borders::ALL)
165
+ .border_style(Style::default().fg(CYBER_CYAN))
166
+ .style(Style::default().bg(BG_PANEL));
167
+
168
+ f.render_widget(detail_block.clone(), popup_area);
169
+
170
+ let inner = detail_block.inner(popup_area);
171
+
172
+ // Create detail text
173
+ let mut detail_lines = vec![
174
+ Line::from(vec![
175
+ Span::styled("Name: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
176
+ Span::styled(&agent.name, Style::default().fg(NEON_GREEN)),
177
+ ]),
178
+ Line::from(""),
179
+ ];
180
+
181
+ if let Some(desc) = &agent.description {
182
+ detail_lines.push(Line::from(vec![
183
+ Span::styled("Description: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
184
+ Span::styled(desc, Style::default().fg(TEXT_PRIMARY)),
185
+ ]));
186
+ detail_lines.push(Line::from(""));
187
+ }
188
+
189
+ detail_lines.push(Line::from(vec![
190
+ Span::styled("Model: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
191
+ Span::styled(&agent.model, Style::default().fg(TEXT_PRIMARY)),
192
+ ]));
193
+ detail_lines.push(Line::from(vec![
194
+ Span::styled("Provider: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
195
+ Span::styled(&agent.provider, Style::default().fg(TEXT_PRIMARY)),
196
+ ]));
197
+
198
+ if let Some(temp) = agent.temperature {
199
+ detail_lines.push(Line::from(vec![
200
+ Span::styled("Temperature: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
201
+ Span::styled(format!("{:.2}", temp), Style::default().fg(TEXT_PRIMARY)),
202
+ ]));
203
+ }
204
+
205
+ if let Some(max_tokens) = agent.max_tokens {
206
+ detail_lines.push(Line::from(vec![
207
+ Span::styled("Max Tokens: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
208
+ Span::styled(format!("{}", max_tokens), Style::default().fg(TEXT_PRIMARY)),
209
+ ]));
210
+ }
211
+
212
+ if !agent.tools.is_empty() {
213
+ detail_lines.push(Line::from(""));
214
+ detail_lines.push(Line::from(vec![
215
+ Span::styled("Tools: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
216
+ ]));
217
+ for tool in &agent.tools {
218
+ detail_lines.push(Line::from(vec![
219
+ Span::raw(" • "),
220
+ Span::styled(tool, Style::default().fg(NEON_GREEN)),
221
+ ]));
222
+ }
223
+ }
224
+
225
+ if let Some(prompt) = &agent.system_prompt {
226
+ detail_lines.push(Line::from(""));
227
+ detail_lines.push(Line::from(vec![
228
+ Span::styled("System Prompt: ", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
229
+ ]));
230
+ // Show first 200 chars of prompt
231
+ let prompt_preview = prompt.chars().take(200).collect::<String>();
232
+ detail_lines.push(Line::from(format!(" {}", prompt_preview)).style(Style::default().fg(TEXT_DIM)));
233
+ }
234
+
235
+ detail_lines.push(Line::from(""));
236
+ detail_lines.push(Line::from(vec![
237
+ Span::styled("Press ", Style::default().fg(TEXT_DIM)),
238
+ Span::styled("ESC", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
239
+ Span::styled(" to close", Style::default().fg(TEXT_DIM)),
240
+ ]));
241
+
242
+ f.render_widget(
243
+ Paragraph::new(detail_lines)
244
+ .wrap(Wrap { trim: false }),
245
+ inner
246
+ );
247
+ }
@@ -0,0 +1,353 @@
1
+ /// Help popup screen
2
+ /// Professional, organized command reference
3
+
4
+ use ratatui::prelude::*;
5
+ use ratatui::widgets::{Block, Borders, Paragraph, Wrap, List, ListItem};
6
+ use crate::app::AppState;
7
+
8
+ // === 4RUNR BRAND COLORS ===
9
+ const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
+ const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
11
+ const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
12
+ const AMBER_WARN: Color = Color::Rgb(255, 191, 0);
13
+ const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
14
+ const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
15
+ const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
16
+ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
17
+
18
+ /// Render help popup overlay
19
+ pub fn render_help(f: &mut Frame, area: Rect, _state: &AppState) {
20
+ // Calculate popup size (80% width, 85% height, centered)
21
+ let popup_width = (area.width * 80 / 100).max(60);
22
+ let popup_height = (area.height * 85 / 100).max(20);
23
+ let popup_x = (area.width.saturating_sub(popup_width)) / 2;
24
+ let popup_y = (area.height.saturating_sub(popup_height)) / 2;
25
+
26
+ let popup_area = Rect {
27
+ x: popup_x,
28
+ y: popup_y,
29
+ width: popup_width,
30
+ height: popup_height,
31
+ };
32
+
33
+ // Render background overlay (dimmed)
34
+ let overlay = Block::default()
35
+ .style(Style::default().bg(Color::Black).fg(Color::Black));
36
+ f.render_widget(overlay, area);
37
+
38
+ // Render help popup
39
+ render_help_content(f, popup_area);
40
+ }
41
+
42
+ fn render_help_content(f: &mut Frame, area: Rect) {
43
+ use ratatui::layout::{Constraint, Direction, Layout};
44
+
45
+ // Split into sections
46
+ let chunks = Layout::default()
47
+ .direction(Direction::Vertical)
48
+ .constraints([
49
+ Constraint::Length(3), // Header
50
+ Constraint::Min(0), // Content
51
+ Constraint::Length(1), // Footer
52
+ ])
53
+ .split(area);
54
+
55
+ // === HEADER ===
56
+ let header_block = Block::default()
57
+ .title(" 📖 4Runr AI Agent OS - Command Reference ")
58
+ .title_style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))
59
+ .borders(Borders::ALL)
60
+ .border_style(Style::default().fg(CYBER_CYAN))
61
+ .style(Style::default().bg(BG_PANEL));
62
+
63
+ let header_text = Line::from(vec![
64
+ Span::styled("Press ", Style::default().fg(TEXT_DIM)),
65
+ Span::styled("ESC", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
66
+ Span::styled(" to close", Style::default().fg(TEXT_DIM)),
67
+ ]);
68
+
69
+ f.render_widget(header_block, chunks[0]);
70
+ f.render_widget(
71
+ Paragraph::new(header_text).alignment(Alignment::Right),
72
+ Rect {
73
+ x: chunks[0].x + 1,
74
+ y: chunks[0].y + 1,
75
+ width: chunks[0].width.saturating_sub(2),
76
+ height: 1,
77
+ }
78
+ );
79
+
80
+ // === CONTENT ===
81
+ let content_chunks = Layout::default()
82
+ .direction(Direction::Horizontal)
83
+ .constraints([
84
+ Constraint::Percentage(40), // Left: Command list
85
+ Constraint::Percentage(60), // Right: Details
86
+ ])
87
+ .split(chunks[1]);
88
+
89
+ // Left panel: Command categories
90
+ render_command_list(f, content_chunks[0]);
91
+
92
+ // Right panel: Command details
93
+ render_command_details(f, content_chunks[1]);
94
+
95
+ // === FOOTER ===
96
+ let footer_text = Line::from(vec![
97
+ Span::styled("Navigate: ", Style::default().fg(TEXT_DIM)),
98
+ Span::styled("↑/↓", Style::default().fg(CYBER_CYAN)),
99
+ Span::styled(" Select | ", Style::default().fg(TEXT_MUTED)),
100
+ Span::styled("ESC", Style::default().fg(NEON_GREEN)),
101
+ Span::styled(" Close", Style::default().fg(TEXT_DIM)),
102
+ ]);
103
+
104
+ f.render_widget(
105
+ Paragraph::new(footer_text)
106
+ .alignment(Alignment::Center)
107
+ .style(Style::default().bg(BG_PANEL)),
108
+ chunks[2]
109
+ );
110
+ }
111
+
112
+ fn render_command_list(f: &mut Frame, area: Rect) {
113
+ use ratatui::layout::{Constraint, Direction, Layout};
114
+
115
+ let chunks = Layout::default()
116
+ .direction(Direction::Vertical)
117
+ .constraints([
118
+ Constraint::Length(8), // Navigation Commands
119
+ Constraint::Length(7), // Local Commands
120
+ Constraint::Min(0), // WebSocket Commands
121
+ ])
122
+ .split(area);
123
+
124
+ // Navigation Commands section
125
+ let nav_block = Block::default()
126
+ .title(" 🧭 Navigation ")
127
+ .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
128
+ .borders(Borders::ALL)
129
+ .border_style(Style::default().fg(CYBER_CYAN))
130
+ .style(Style::default().bg(BG_PANEL));
131
+
132
+ let nav_items = vec![
133
+ ListItem::new(Line::from(vec![
134
+ Span::styled("build", Style::default().fg(NEON_GREEN)),
135
+ ])),
136
+ ListItem::new(Line::from(vec![
137
+ Span::styled("runs", Style::default().fg(NEON_GREEN)),
138
+ ])),
139
+ ListItem::new(Line::from(vec![
140
+ Span::styled("config", Style::default().fg(NEON_GREEN)),
141
+ Span::styled(", ", Style::default().fg(TEXT_DIM)),
142
+ Span::styled("settings", Style::default().fg(NEON_GREEN)),
143
+ ])),
144
+ ListItem::new(Line::from(vec![
145
+ Span::styled("ESC", Style::default().fg(NEON_GREEN)),
146
+ ])),
147
+ ];
148
+
149
+ f.render_widget(nav_block.clone(), chunks[0]);
150
+ f.render_widget(
151
+ List::new(nav_items)
152
+ .style(Style::default().fg(TEXT_PRIMARY)),
153
+ nav_block.inner(chunks[0])
154
+ );
155
+
156
+ // Local Commands section
157
+ let local_block = Block::default()
158
+ .title(" 💻 Local ")
159
+ .title_style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))
160
+ .borders(Borders::ALL)
161
+ .border_style(Style::default().fg(BRAND_PURPLE))
162
+ .style(Style::default().bg(BG_PANEL));
163
+
164
+ let local_items = vec![
165
+ ListItem::new(Line::from(vec![
166
+ Span::styled("quit", Style::default().fg(NEON_GREEN)),
167
+ Span::styled(", ", Style::default().fg(TEXT_DIM)),
168
+ Span::styled("exit", Style::default().fg(NEON_GREEN)),
169
+ ])),
170
+ ListItem::new(Line::from(vec![
171
+ Span::styled("clear", Style::default().fg(NEON_GREEN)),
172
+ ])),
173
+ ListItem::new(Line::from(vec![
174
+ Span::styled("help", Style::default().fg(NEON_GREEN)),
175
+ ])),
176
+ ListItem::new(Line::from(vec![
177
+ Span::styled(":perf", Style::default().fg(NEON_GREEN)),
178
+ ])),
179
+ ];
180
+
181
+ f.render_widget(local_block.clone(), chunks[1]);
182
+ f.render_widget(
183
+ List::new(local_items)
184
+ .style(Style::default().fg(TEXT_PRIMARY)),
185
+ local_block.inner(chunks[1])
186
+ );
187
+
188
+ // WebSocket Commands section
189
+ let ws_block = Block::default()
190
+ .title(" 🌐 WebSocket (requires connection) ")
191
+ .title_style(Style::default().fg(AMBER_WARN).add_modifier(Modifier::BOLD))
192
+ .borders(Borders::ALL)
193
+ .border_style(Style::default().fg(AMBER_WARN))
194
+ .style(Style::default().bg(BG_PANEL));
195
+
196
+ let ws_items = vec![
197
+ ListItem::new(Line::from(vec![
198
+ Span::styled("agent.list", Style::default().fg(NEON_GREEN)),
199
+ ])),
200
+ ListItem::new(Line::from(vec![
201
+ Span::styled("agent.get", Style::default().fg(NEON_GREEN)),
202
+ ])),
203
+ ListItem::new(Line::from(vec![
204
+ Span::styled("agent.create", Style::default().fg(NEON_GREEN)),
205
+ ])),
206
+ ListItem::new(Line::from(vec![
207
+ Span::styled("agent.delete", Style::default().fg(NEON_GREEN)),
208
+ ])),
209
+ ListItem::new(Line::from(vec![
210
+ Span::styled("system.status", Style::default().fg(NEON_GREEN)),
211
+ ])),
212
+ ListItem::new(Line::from(vec![
213
+ Span::styled("run.list", Style::default().fg(NEON_GREEN)),
214
+ ])),
215
+ ListItem::new(Line::from(vec![
216
+ Span::styled("tool.list", Style::default().fg(NEON_GREEN)),
217
+ ])),
218
+ ];
219
+
220
+ f.render_widget(ws_block.clone(), chunks[2]);
221
+ f.render_widget(
222
+ List::new(ws_items)
223
+ .style(Style::default().fg(TEXT_PRIMARY)),
224
+ ws_block.inner(chunks[2])
225
+ );
226
+ }
227
+
228
+ fn render_command_details(f: &mut Frame, area: Rect) {
229
+ use ratatui::layout::{Constraint, Direction, Layout};
230
+
231
+ let chunks = Layout::default()
232
+ .direction(Direction::Vertical)
233
+ .constraints([
234
+ Constraint::Percentage(50), // Command descriptions
235
+ Constraint::Percentage(50), // Screen controls
236
+ ])
237
+ .split(area);
238
+
239
+ // Command descriptions
240
+ let desc_block = Block::default()
241
+ .title(" 📋 Command Details ")
242
+ .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
243
+ .borders(Borders::ALL)
244
+ .border_style(Style::default().fg(CYBER_CYAN))
245
+ .style(Style::default().bg(BG_PANEL));
246
+
247
+ let desc_text = vec![
248
+ Line::from(vec![
249
+ Span::styled("build", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
250
+ Span::styled(" - Open Agent Builder (6-step wizard)", Style::default().fg(TEXT_PRIMARY)),
251
+ ]),
252
+ Line::from(""),
253
+ Line::from(vec![
254
+ Span::styled("runs", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
255
+ Span::styled(" - Open Run Manager (list, filter, sort)", Style::default().fg(TEXT_PRIMARY)),
256
+ ]),
257
+ Line::from(""),
258
+ Line::from(vec![
259
+ Span::styled("config, settings", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
260
+ Span::styled(" - Open Settings (mode, AI provider)", Style::default().fg(TEXT_PRIMARY)),
261
+ ]),
262
+ Line::from(""),
263
+ Line::from(vec![
264
+ Span::styled("ESC", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
265
+ Span::styled(" - Close overlay/popup or clear input", Style::default().fg(TEXT_PRIMARY)),
266
+ ]),
267
+ Line::from(""),
268
+ Line::from(vec![
269
+ Span::styled("quit, exit", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
270
+ Span::styled(" - Exit application", Style::default().fg(TEXT_PRIMARY)),
271
+ ]),
272
+ Line::from(""),
273
+ Line::from(vec![
274
+ Span::styled("clear", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
275
+ Span::styled(" - Clear operations log", Style::default().fg(TEXT_PRIMARY)),
276
+ ]),
277
+ Line::from(""),
278
+ Line::from(vec![
279
+ Span::styled("help", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
280
+ Span::styled(" - Show this help", Style::default().fg(TEXT_PRIMARY)),
281
+ ]),
282
+ Line::from(""),
283
+ Line::from(vec![
284
+ Span::styled(":perf", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
285
+ Span::styled(" - Show performance stats", Style::default().fg(TEXT_PRIMARY)),
286
+ ]),
287
+ ];
288
+
289
+ f.render_widget(desc_block.clone(), chunks[0]);
290
+ f.render_widget(
291
+ Paragraph::new(desc_text)
292
+ .wrap(Wrap { trim: false }),
293
+ desc_block.inner(chunks[0])
294
+ );
295
+
296
+ // Screen controls
297
+ let controls_block = Block::default()
298
+ .title(" ⌨️ Screen Controls ")
299
+ .title_style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))
300
+ .borders(Borders::ALL)
301
+ .border_style(Style::default().fg(BRAND_PURPLE))
302
+ .style(Style::default().bg(BG_PANEL));
303
+
304
+ let controls_text = vec![
305
+ Line::from(vec![
306
+ Span::styled("Agent Builder:", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
307
+ ]),
308
+ Line::from(vec![
309
+ Span::raw(" "),
310
+ Span::styled("Enter", Style::default().fg(NEON_GREEN)),
311
+ Span::styled(" = Next | ", Style::default().fg(TEXT_DIM)),
312
+ Span::styled("Backspace", Style::default().fg(NEON_GREEN)),
313
+ Span::styled(" = Prev | ", Style::default().fg(TEXT_DIM)),
314
+ Span::styled("ESC", Style::default().fg(NEON_GREEN)),
315
+ Span::styled(" = Cancel", Style::default().fg(TEXT_DIM)),
316
+ ]),
317
+ Line::from(""),
318
+ Line::from(vec![
319
+ Span::styled("Run Manager:", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
320
+ ]),
321
+ Line::from(vec![
322
+ Span::raw(" "),
323
+ Span::styled("↑/↓", Style::default().fg(NEON_GREEN)),
324
+ Span::styled(" = Navigate | ", Style::default().fg(TEXT_DIM)),
325
+ Span::styled("F", Style::default().fg(NEON_GREEN)),
326
+ Span::styled(" = Filter | ", Style::default().fg(TEXT_DIM)),
327
+ Span::styled("S", Style::default().fg(NEON_GREEN)),
328
+ Span::styled(" = Sort | ", Style::default().fg(TEXT_DIM)),
329
+ Span::styled("R", Style::default().fg(NEON_GREEN)),
330
+ Span::styled(" = Refresh", Style::default().fg(TEXT_DIM)),
331
+ ]),
332
+ Line::from(""),
333
+ Line::from(vec![
334
+ Span::styled("Settings:", Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)),
335
+ ]),
336
+ Line::from(vec![
337
+ Span::raw(" "),
338
+ Span::styled("↑/↓", Style::default().fg(NEON_GREEN)),
339
+ Span::styled(" = Navigate | ", Style::default().fg(TEXT_DIM)),
340
+ Span::styled("Space", Style::default().fg(NEON_GREEN)),
341
+ Span::styled(" = Toggle | ", Style::default().fg(TEXT_DIM)),
342
+ Span::styled("Enter", Style::default().fg(NEON_GREEN)),
343
+ Span::styled(" = Save", Style::default().fg(TEXT_DIM)),
344
+ ]),
345
+ ];
346
+
347
+ f.render_widget(controls_block.clone(), chunks[1]);
348
+ f.render_widget(
349
+ Paragraph::new(controls_text)
350
+ .wrap(Wrap { trim: false }),
351
+ controls_block.inner(chunks[1])
352
+ );
353
+ }
@@ -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.4.1";
123
+ const PACKAGE_VERSION: &str = "2.4.2";
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)),
@@ -422,64 +422,6 @@ fn mem_color(value: f64) -> Color {
422
422
  else if value > 0.6 { Color::Rgb(255, 191, 0) }
423
423
  else { CYBER_CYAN }
424
424
  }
425
-
426
- /// Parse help log format and return styled spans
427
- fn parse_help_log(log: &str) -> Vec<Span> {
428
- // Remove [HELP] prefix if present
429
- let content = if log.starts_with("[HELP] ") {
430
- &log[7..] // Skip "[HELP] "
431
- } else if log.starts_with("[HELP]") {
432
- &log[6..] // Skip "[HELP]"
433
- } else {
434
- log
435
- };
436
-
437
- // Check for separator line (all dashes or equals)
438
- if content.trim().chars().all(|c| c == '─' || c == '-' || c == '═') {
439
- return vec![
440
- Span::styled("> ", Style::default().fg(CYBER_CYAN)),
441
- Span::styled("[HELP] ", Style::default().fg(CYBER_CYAN)),
442
- Span::styled(content, Style::default().fg(TEXT_MUTED)),
443
- ];
444
- }
445
-
446
- // Check for section header (ends with colon)
447
- if content.ends_with(':') && !content.starts_with(" ") {
448
- return vec![
449
- Span::styled("> ", Style::default().fg(CYBER_CYAN)),
450
- Span::styled("[HELP] ", Style::default().fg(CYBER_CYAN)),
451
- Span::styled(
452
- content,
453
- Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)
454
- ),
455
- ];
456
- }
457
-
458
- // Check for command line (starts with " " and contains " - ")
459
- if content.starts_with(" ") && content.contains(" - ") {
460
- if let Some(dash_pos) = content.find(" - ") {
461
- let command_part = content[2..dash_pos].trim_end();
462
- let desc_part = &content[dash_pos + 3..];
463
-
464
- return vec![
465
- Span::styled("> ", Style::default().fg(CYBER_CYAN)),
466
- Span::styled("[HELP] ", Style::default().fg(CYBER_CYAN)),
467
- Span::styled(" ", Style::default().fg(TEXT_PRIMARY)),
468
- Span::styled(command_part, Style::default().fg(NEON_GREEN)),
469
- Span::styled(" - ", Style::default().fg(TEXT_MUTED)),
470
- Span::styled(desc_part, Style::default().fg(TEXT_PRIMARY)),
471
- ];
472
- }
473
- }
474
-
475
- // Default: regular help message
476
- vec![
477
- Span::styled("> ", Style::default().fg(CYBER_CYAN)),
478
- Span::styled("[HELP] ", Style::default().fg(CYBER_CYAN)),
479
- Span::styled(content, Style::default().fg(TEXT_PRIMARY)),
480
- ]
481
- }
482
-
483
425
  fn render_center_column(f: &mut Frame, area: Rect, state: &AppState) {
484
426
  let panel_area = Rect {
485
427
  x: area.x + 1,
@@ -525,11 +467,7 @@ fn render_center_column(f: &mut Frame, area: Rect, state: &AppState) {
525
467
 
526
468
  // Show real logs with proper formatting (reversed order - newest first)
527
469
  for log in state.logs.iter().rev().skip(start_idx).take(visible_height) {
528
- // Check if this is a HELP log
529
- if log.starts_with("[HELP]") {
530
- let spans = parse_help_log(log);
531
- lines.push(Line::from(spans));
532
- } else if log.starts_with("[") {
470
+ if log.starts_with("[") {
533
471
  // Parse log format: [COMPONENT] message
534
472
  if let Some(bracket_end) = log.find(']') {
535
473
  let component = &log[1..bracket_end];
@@ -1,5 +1,7 @@
1
1
  pub mod agent_builder;
2
+ pub mod agent_list;
2
3
  pub mod boot;
4
+ pub mod help;
3
5
  pub mod layout;
4
6
  pub mod run_manager;
5
7
  pub mod safe_viewport;
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "type": "module",
5
+ "private": false,
5
6
  "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.3.5: Fixed boot screen logo rendering (restored original working version), fully functional Agent Builder with input handling and TUI styling. Built with Rust + Ratatui. ⚠️ Pre-MVP / Development Phase",
6
7
  "main": "dist/index.js",
7
8
  "bin": {