4runr-os 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,67 @@ pub enum AppMode {
29
29
  Main,
30
30
  }
31
31
 
32
+ #[derive(Debug, Clone, PartialEq)]
33
+ pub enum OperationMode {
34
+ Local, // No Gateway connection
35
+ Connected, // Connected to Gateway
36
+ }
37
+
38
+ impl OperationMode {
39
+ pub fn as_str(&self) -> &str {
40
+ match self {
41
+ OperationMode::Local => "LOCAL",
42
+ OperationMode::Connected => "CONNECTED",
43
+ }
44
+ }
45
+ }
46
+
47
+ #[derive(Debug, Clone, PartialEq)]
48
+ pub enum PortalField {
49
+ GatewayUrl,
50
+ Username,
51
+ }
52
+
53
+ #[derive(Debug, Clone)]
54
+ pub struct ConnectionPortalState {
55
+ pub gateway_url: String,
56
+ pub username: String,
57
+ pub focused_field: PortalField,
58
+ pub connecting: bool,
59
+ pub error: Option<String>,
60
+ pub last_successful_url: Option<String>,
61
+ }
62
+
63
+ impl Default for ConnectionPortalState {
64
+ fn default() -> Self {
65
+ Self {
66
+ gateway_url: "http://localhost:3001".to_string(),
67
+ username: String::new(),
68
+ focused_field: PortalField::GatewayUrl,
69
+ connecting: false,
70
+ error: None,
71
+ last_successful_url: None,
72
+ }
73
+ }
74
+ }
75
+
76
+ impl ConnectionPortalState {
77
+ pub fn reset(&mut self) {
78
+ self.gateway_url = self.last_successful_url.clone().unwrap_or_else(|| "http://localhost:3001".to_string());
79
+ self.username = String::new();
80
+ self.focused_field = PortalField::GatewayUrl;
81
+ self.connecting = false;
82
+ self.error = None;
83
+ }
84
+
85
+ pub fn toggle_field(&mut self) {
86
+ self.focused_field = match self.focused_field {
87
+ PortalField::GatewayUrl => PortalField::Username,
88
+ PortalField::Username => PortalField::GatewayUrl,
89
+ };
90
+ }
91
+ }
92
+
32
93
  #[derive(Debug, Clone)]
33
94
  pub struct AppState {
34
95
  // Navigation state (NEW - replaces simple mode)
@@ -97,6 +158,19 @@ pub struct AppState {
97
158
  pub run_manager: RunManagerState,
98
159
  pub settings: SettingsState,
99
160
  pub agent_list: AgentListState,
161
+ pub connection_portal: ConnectionPortalState,
162
+
163
+ // Command tracking for response handling (Step 5.1)
164
+ pub pending_agent_create_id: Option<String>,
165
+ pub pending_agent_list_id: Option<String>,
166
+ pub pending_agent_delete_id: Option<String>,
167
+ pub pending_gateway_connect_id: Option<String>,
168
+
169
+ // Deletion confirmation (Step 5.5)
170
+ pub agent_delete_requested: bool,
171
+
172
+ // Mode tracking (Step 7)
173
+ pub operation_mode: OperationMode,
100
174
 
101
175
  // Local cache
102
176
  pub cache: Option<Cache>,
@@ -128,11 +202,12 @@ impl Default for AppState {
128
202
  network_status: "Not Connected".to_string(),
129
203
  logs: VecDeque::from([
130
204
  "[SYSTEM] 4Runr AI Agent OS initialized".to_string(),
205
+ "[MODE] Starting in LOCAL mode (offline)".to_string(),
131
206
  "[SHIELD] Safety layer active (enforce mode)".to_string(),
132
207
  "[SHIELD] Detectors: PII, Injection, Hallucination".to_string(),
133
208
  "[SENTINEL] Real-time monitoring ready".to_string(),
134
- "[GATEWAY] Not connected - run with --gateway to connect".to_string(),
135
- "[SYSTEM] Type 'help' for available commands".to_string(),
209
+ "[HELP] Type 'connect portal' to connect to Gateway server".to_string(),
210
+ "[HELP] Type 'help' for available commands".to_string(),
136
211
  ]),
137
212
  capabilities: vec![
138
213
  "text-analysis".to_string(),
@@ -155,6 +230,13 @@ impl Default for AppState {
155
230
  run_manager: RunManagerState::default(),
156
231
  settings: SettingsState::default(),
157
232
  agent_list: AgentListState::default(),
233
+ connection_portal: ConnectionPortalState::default(),
234
+ pending_agent_create_id: None,
235
+ pending_agent_list_id: None,
236
+ pending_agent_delete_id: None,
237
+ pending_gateway_connect_id: None,
238
+ agent_delete_requested: false,
239
+ operation_mode: OperationMode::Local,
158
240
  cache: Cache::new().ok(),
159
241
  cache_loaded: false,
160
242
  }
@@ -370,6 +452,11 @@ impl App {
370
452
  return self.handle_settings_input(key);
371
453
  }
372
454
 
455
+ // === CONNECTION PORTAL INPUT HANDLING ===
456
+ if self.state.navigation.current_screen() == &Screen::ConnectionPortal {
457
+ return self.handle_connection_portal_input(key, ws_client);
458
+ }
459
+
373
460
  // === AGENT LIST INPUT HANDLING ===
374
461
  if self.state.navigation.current_screen() == &Screen::AgentList {
375
462
  return self.handle_agent_list_input(key);
@@ -432,6 +519,9 @@ impl App {
432
519
  self.state.logs.push_back("".to_string());
433
520
 
434
521
  // Navigation Commands
522
+ self.state.logs.push_back(" disconnect - Disconnect from Gateway (return to LOCAL mode)".to_string());
523
+ self.state.logs.push_back(" connect portal - Open Connection Portal (connect to Gateway)".to_string());
524
+ self.state.logs.push_back(" a, agents - Open Agent List (view, select, delete)".to_string());
435
525
  self.state.logs.push_back(" config, settings - Open Settings (mode, AI provider)".to_string());
436
526
  self.state.logs.push_back(" runs - Open Run Manager (list, filter, sort)".to_string());
437
527
  self.state.logs.push_back(" build - Open Agent Builder (6-step wizard)".to_string());
@@ -488,6 +578,38 @@ impl App {
488
578
  self.push_overlay(Screen::Settings);
489
579
  self.state.logs.push_back("[NAV] Opening Settings...".into());
490
580
  }
581
+ "a" | "agents" | "agent list" => {
582
+ self.push_overlay(Screen::AgentList);
583
+ self.state.logs.push_back("[NAV] Opening Agent List...".into());
584
+ // Request agent list from backend
585
+ if let Some(ws) = ws_client {
586
+ if let Ok(list_id) = ws.send_command("agent.list", None) {
587
+ self.state.pending_agent_list_id = Some(list_id);
588
+ }
589
+ }
590
+ }
591
+ "connect portal" | "portal" => {
592
+ self.push_overlay(Screen::ConnectionPortal);
593
+ self.state.connection_portal.reset();
594
+ self.state.logs.push_back("[NAV] Opening Connection Portal...".into());
595
+ }
596
+ "disconnect" => {
597
+ // Disconnect from Gateway
598
+ if let Some(ws) = ws_client {
599
+ match ws.send_command("gateway.disconnect", None) {
600
+ Ok(id) => {
601
+ let short_id = &id[id.len().saturating_sub(8)..];
602
+ self.state.logs.push_back(format!("[GATEWAY] Disconnecting (id: {})", short_id));
603
+ // Mode will be updated when response is received
604
+ }
605
+ Err(e) => {
606
+ self.state.logs.push_back(format!("[ERROR] Failed to disconnect: {}", e));
607
+ }
608
+ }
609
+ } else {
610
+ self.state.logs.push_back("[ERROR] WebSocket not connected".into());
611
+ }
612
+ }
491
613
  _ => {
492
614
  // Try to parse as WebSocket command
493
615
  if let Some(ws) = ws_client {
@@ -636,6 +758,10 @@ impl App {
636
758
  use crate::ui::agent_list;
637
759
  agent_list::render(f, &self.state);
638
760
  }
761
+ Screen::ConnectionPortal => {
762
+ use crate::ui::connection_portal;
763
+ connection_portal::render(f, &self.state);
764
+ }
639
765
  Screen::Confirmation { message, action } => {
640
766
  // TODO: Implement confirmation popup
641
767
  // For now, just render the base screen
@@ -1024,6 +1150,8 @@ impl App {
1024
1150
  Ok(id) => {
1025
1151
  let short_id = &id[id.len().saturating_sub(8)..];
1026
1152
  self.add_log(format!("[AGENT] Sent agent.create (id: {})", short_id));
1153
+ // Store command ID for response tracking (Step 5.1)
1154
+ self.state.pending_agent_create_id = Some(id);
1027
1155
  }
1028
1156
  Err(e) => {
1029
1157
  self.add_log(format!("[ERROR] Failed to create agent: {}", e));
@@ -1275,6 +1403,183 @@ impl App {
1275
1403
  }
1276
1404
  }
1277
1405
 
1406
+ // Delete agent (Step 5.5)
1407
+ KeyCode::Delete | KeyCode::Char('d') | KeyCode::Char('D') => {
1408
+ // Only allow deletion in list view (not in detail view)
1409
+ if self.state.agent_list.detail_view.is_none() && !self.state.agent_list.agents.is_empty() {
1410
+ self.state.agent_delete_requested = true;
1411
+ self.request_render("agent_delete_requested");
1412
+ }
1413
+ }
1414
+
1415
+ _ => {}
1416
+ }
1417
+
1418
+ Ok(false)
1419
+ }
1420
+
1421
+ /// Delete selected agent (Step 5.5)
1422
+ pub fn delete_selected_agent(&mut self, ws_client: &Option<crate::websocket::WebSocketClient>) {
1423
+ if self.state.agent_list.agents.is_empty() {
1424
+ return;
1425
+ }
1426
+
1427
+ let agent_name = self.state.agent_list.agents[self.state.agent_list.selected_index].name.clone();
1428
+
1429
+ // Send agent.delete command via WebSocket
1430
+ if let Some(ws) = ws_client {
1431
+ let delete_data = serde_json::json!({
1432
+ "name": agent_name
1433
+ });
1434
+
1435
+ match ws.send_command("agent.delete", Some(delete_data)) {
1436
+ Ok(id) => {
1437
+ let short_id = &id[id.len().saturating_sub(8)..];
1438
+ self.add_log(format!("[AGENT] Sent agent.delete for '{}' (id: {})", agent_name, short_id));
1439
+ // Store command ID for response tracking
1440
+ self.state.pending_agent_delete_id = Some(id);
1441
+ }
1442
+ Err(e) => {
1443
+ self.add_log(format!("[ERROR] Failed to delete agent: {}", e));
1444
+ }
1445
+ }
1446
+ } else {
1447
+ self.add_log("[ERROR] WebSocket not connected. Cannot delete agent.".to_string());
1448
+ }
1449
+ }
1450
+
1451
+ /// Get selected agent (Step 5.6)
1452
+ pub fn get_selected_agent(&self) -> Option<&AgentInfo> {
1453
+ if self.state.agent_list.agents.is_empty() {
1454
+ return None;
1455
+ }
1456
+ self.state.agent_list.agents.get(self.state.agent_list.selected_index)
1457
+ }
1458
+
1459
+ /// Get selected agent name (Step 5.6)
1460
+ pub fn get_selected_agent_name(&self) -> Option<String> {
1461
+ self.get_selected_agent().map(|agent| agent.name.clone())
1462
+ }
1463
+
1464
+ /// Handle input when Connection Portal screen is active (Step 7)
1465
+ fn handle_connection_portal_input(&mut self, key: KeyEvent, ws_client: Option<&WebSocketClient>) -> anyhow::Result<bool> {
1466
+ use crossterm::event::KeyModifiers;
1467
+
1468
+ // Ctrl+C/Q to exit
1469
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
1470
+ match key.code {
1471
+ KeyCode::Char('c') | KeyCode::Char('q') => return Ok(true),
1472
+ _ => return Ok(false),
1473
+ }
1474
+ }
1475
+
1476
+ // Disable input if connecting
1477
+ if self.state.connection_portal.connecting {
1478
+ return Ok(false);
1479
+ }
1480
+
1481
+ match key.code {
1482
+ // ESC - Cancel and close portal
1483
+ KeyCode::Esc => {
1484
+ self.pop_overlay();
1485
+ self.add_log("[NAV] Connection Portal closed".to_string());
1486
+ self.request_render("portal_close");
1487
+ }
1488
+
1489
+ // Tab - Switch between fields
1490
+ KeyCode::Tab | KeyCode::BackTab => {
1491
+ self.state.connection_portal.toggle_field();
1492
+ self.request_render("portal_field_toggle");
1493
+ }
1494
+
1495
+ // Enter - Connect to Gateway
1496
+ KeyCode::Enter => {
1497
+ let url = self.state.connection_portal.gateway_url.clone();
1498
+ let username = if self.state.connection_portal.username.is_empty() {
1499
+ None
1500
+ } else {
1501
+ Some(self.state.connection_portal.username.clone())
1502
+ };
1503
+
1504
+ // Validate URL format
1505
+ if url.is_empty() {
1506
+ self.state.connection_portal.error = Some("Gateway URL is required".to_string());
1507
+ self.request_render("portal_error");
1508
+ return Ok(false);
1509
+ }
1510
+
1511
+ if !url.starts_with("http://") && !url.starts_with("https://") {
1512
+ self.state.connection_portal.error = Some("URL must start with http:// or https://".to_string());
1513
+ self.request_render("portal_error");
1514
+ return Ok(false);
1515
+ }
1516
+
1517
+ // Set connecting state
1518
+ self.state.connection_portal.connecting = true;
1519
+ self.state.connection_portal.error = None;
1520
+
1521
+ // Send gateway.connect command
1522
+ if let Some(ws) = ws_client {
1523
+ let connect_data = if let Some(username) = username {
1524
+ serde_json::json!({
1525
+ "url": url,
1526
+ "username": username
1527
+ })
1528
+ } else {
1529
+ serde_json::json!({
1530
+ "url": url
1531
+ })
1532
+ };
1533
+
1534
+ match ws.send_command("gateway.connect", Some(connect_data)) {
1535
+ Ok(id) => {
1536
+ let short_id = &id[id.len().saturating_sub(8)..];
1537
+ self.add_log(format!("[GATEWAY] Connecting to {} (id: {})", url, short_id));
1538
+ self.state.pending_gateway_connect_id = Some(id);
1539
+ }
1540
+ Err(e) => {
1541
+ self.state.connection_portal.connecting = false;
1542
+ self.state.connection_portal.error = Some(format!("Failed to send command: {}", e));
1543
+ self.add_log(format!("[ERROR] Gateway connection failed: {}", e));
1544
+ }
1545
+ }
1546
+ } else {
1547
+ self.state.connection_portal.connecting = false;
1548
+ self.state.connection_portal.error = Some("WebSocket not connected".to_string());
1549
+ self.add_log("[ERROR] WebSocket not connected. Cannot connect to Gateway.".to_string());
1550
+ }
1551
+
1552
+ self.request_render("portal_connect");
1553
+ }
1554
+
1555
+ // Typing - edit focused field
1556
+ KeyCode::Char(c) => {
1557
+ match self.state.connection_portal.focused_field {
1558
+ PortalField::GatewayUrl => {
1559
+ self.state.connection_portal.gateway_url.push(c);
1560
+ }
1561
+ PortalField::Username => {
1562
+ self.state.connection_portal.username.push(c);
1563
+ }
1564
+ }
1565
+ self.state.connection_portal.error = None;
1566
+ self.request_render("portal_input");
1567
+ }
1568
+
1569
+ // Backspace - delete character
1570
+ KeyCode::Backspace => {
1571
+ match self.state.connection_portal.focused_field {
1572
+ PortalField::GatewayUrl => {
1573
+ self.state.connection_portal.gateway_url.pop();
1574
+ }
1575
+ PortalField::Username => {
1576
+ self.state.connection_portal.username.pop();
1577
+ }
1578
+ }
1579
+ self.state.connection_portal.error = None;
1580
+ self.request_render("portal_delete");
1581
+ }
1582
+
1278
1583
  _ => {}
1279
1584
  }
1280
1585
 
@@ -192,6 +192,11 @@ fn main() -> Result<()> {
192
192
  app.state.agent_list.selected_index = 0;
193
193
  app.state.agent_list.detail_view = None;
194
194
 
195
+ // Clear pending agent.list ID if this response matches (Step 5.2)
196
+ if Some(&resp.id) == app.state.pending_agent_list_id.as_ref() {
197
+ app.state.pending_agent_list_id = None;
198
+ }
199
+
195
200
  // Only open AgentList overlay if we're on Main screen (not during boot)
196
201
  use crate::screens::Screen;
197
202
  if app.state.navigation.current_screen() == &Screen::Main {
@@ -199,6 +204,78 @@ fn main() -> Result<()> {
199
204
  app.request_render("agent_list_opened");
200
205
  }
201
206
  }
207
+ }
208
+ // Handle agent.create response (Step 5.1)
209
+ else if let Some(agent) = obj.get("agent") {
210
+ // Check if this is a response to our agent.create command
211
+ if Some(&resp.id) == app.state.pending_agent_create_id.as_ref() {
212
+ app.state.pending_agent_create_id = None;
213
+
214
+ // Extract agent name for success message
215
+ let agent_name = agent.as_object()
216
+ .and_then(|a| a.get("name"))
217
+ .and_then(|n| n.as_str())
218
+ .unwrap_or("unknown");
219
+
220
+ app.add_log(format!("✓ [{}] Agent '{}' created successfully", short_id, agent_name));
221
+
222
+ // Refresh agent list (Step 5.3)
223
+ if let Some(ref ws) = ws_client {
224
+ if let Ok(list_id) = ws.send_command("agent.list", None) {
225
+ app.state.pending_agent_list_id = Some(list_id);
226
+ }
227
+ }
228
+ } else {
229
+ app.add_log(format!("✓ [{}] Agent response received", short_id));
230
+ }
231
+ }
232
+ // Handle agent.delete response (Step 5.5)
233
+ else if let Some(deleted) = obj.get("deleted") {
234
+ if deleted.as_bool().unwrap_or(false) {
235
+ if Some(&resp.id) == app.state.pending_agent_delete_id.as_ref() {
236
+ app.state.pending_agent_delete_id = None;
237
+ app.add_log(format!("✓ [{}] Agent deleted successfully", short_id));
238
+
239
+ // Refresh agent list (Step 5.5)
240
+ if let Some(ref ws) = ws_client {
241
+ if let Ok(list_id) = ws.send_command("agent.list", None) {
242
+ app.state.pending_agent_list_id = Some(list_id);
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+ // Handle gateway.connect response (Step 7.5)
249
+ else if let Some(connected) = obj.get("connected") {
250
+ if connected.as_bool().unwrap_or(false) {
251
+ if Some(&resp.id) == app.state.pending_gateway_connect_id.as_ref() {
252
+ app.state.pending_gateway_connect_id = None;
253
+
254
+ use crate::app::OperationMode;
255
+ app.state.operation_mode = OperationMode::Connected;
256
+
257
+ let url = obj.get("url")
258
+ .and_then(|v| v.as_str())
259
+ .unwrap_or("unknown");
260
+
261
+ app.state.gateway_url = Some(url.to_string());
262
+ app.state.connection_portal.last_successful_url = Some(url.to_string());
263
+ app.state.connection_portal.connecting = false;
264
+
265
+ app.add_log(format!("✓ [{}] Connected to Gateway: {}", short_id, url));
266
+ app.add_log(format!("[MODE] Switched to CONNECTED mode"));
267
+
268
+ // Close portal
269
+ app.pop_overlay();
270
+ }
271
+ } else {
272
+ // Handle disconnect response (Step 7.8)
273
+ use crate::app::OperationMode;
274
+ app.state.operation_mode = OperationMode::Local;
275
+ app.state.gateway_url = None;
276
+ app.add_log(format!("✓ [{}] Disconnected from Gateway", short_id));
277
+ app.add_log(format!("[MODE] Switched to LOCAL mode"));
278
+ }
202
279
  } else {
203
280
  app.add_log(format!("✓ [{}] Success", short_id));
204
281
  }
@@ -212,25 +289,45 @@ fn main() -> Result<()> {
212
289
  let short_id = &resp.id[resp.id.len().saturating_sub(8)..];
213
290
  let error_msg = resp.payload.error.unwrap_or_else(|| "Unknown error".to_string());
214
291
 
215
- // Try to extract command name from response for better error messages
216
- let command_hint = if resp.id.contains("agent") {
217
- " (agent command)"
218
- } else if resp.id.contains("system") {
219
- " (system command)"
220
- } else if resp.id.contains("run") {
221
- " (run command)"
222
- } else if resp.id.contains("tool") {
223
- " (tool command)"
224
- } else {
225
- ""
226
- };
227
-
228
- app.add_log(format!(
229
- "✗ [{}]{} Error: {}",
230
- short_id,
231
- command_hint,
232
- error_msg
233
- ));
292
+ // Check if this is a response to agent.create command (Step 5.1)
293
+ if Some(&resp.id) == app.state.pending_agent_create_id.as_ref() {
294
+ app.state.pending_agent_create_id = None;
295
+ app.add_log(format!("✗ [{}] Agent creation failed: {}", short_id, error_msg));
296
+ }
297
+ // Check if this is a response to agent.delete command (Step 5.5)
298
+ else if Some(&resp.id) == app.state.pending_agent_delete_id.as_ref() {
299
+ app.state.pending_agent_delete_id = None;
300
+ app.add_log(format!(" [{}] Agent deletion failed: {}", short_id, error_msg));
301
+ }
302
+ // Check if this is a response to gateway.connect command (Step 7.5)
303
+ else if Some(&resp.id) == app.state.pending_gateway_connect_id.as_ref() {
304
+ app.state.pending_gateway_connect_id = None;
305
+ app.state.connection_portal.connecting = false;
306
+ app.state.connection_portal.error = Some(error_msg.clone());
307
+ app.add_log(format!("✗ [{}] Gateway connection failed: {}", short_id, error_msg));
308
+ }
309
+ // Generic error handling
310
+ else {
311
+ // Try to extract command name from response for better error messages
312
+ let command_hint = if resp.id.contains("agent") {
313
+ " (agent command)"
314
+ } else if resp.id.contains("system") {
315
+ " (system command)"
316
+ } else if resp.id.contains("run") {
317
+ " (run command)"
318
+ } else if resp.id.contains("tool") {
319
+ " (tool command)"
320
+ } else {
321
+ ""
322
+ };
323
+
324
+ app.add_log(format!(
325
+ "✗ [{}]{} Error: {}",
326
+ short_id,
327
+ command_hint,
328
+ error_msg
329
+ ));
330
+ }
234
331
  }
235
332
  }
236
333
  WsClientMessage::Event(event) => {
@@ -279,7 +376,7 @@ fn main() -> Result<()> {
279
376
  // └─────────────────────────────────────────────────────────┘
280
377
  // poll() with short timeout (16ms) - returns immediately if no input
281
378
  // This allows loop to run ~60 times/second for smooth animations
282
- if crossterm::event::poll(Duration::from_millis(16))? {
379
+ if crossterm::event::poll(Duration::from_millis(16))? {
283
380
  if let Event::Key(key) = event::read()? {
284
381
  if key.kind == KeyEventKind::Press {
285
382
  if app.handle_input(key, &mut io_handler, ws_client.as_ref())? {
@@ -290,6 +387,12 @@ fn main() -> Result<()> {
290
387
  }
291
388
  // If no input, loop continues immediately - animations keep running!
292
389
 
390
+ // Check for pending agent deletion (Step 5.5)
391
+ if app.state.agent_delete_requested {
392
+ app.state.agent_delete_requested = false;
393
+ app.delete_selected_agent(&ws_client);
394
+ }
395
+
293
396
  // Check for IO updates (non-blocking)
294
397
  io_handler.update(&mut app).ok();
295
398
  }
@@ -20,6 +20,7 @@ pub enum Screen {
20
20
  RunManager,
21
21
  Settings,
22
22
  AgentList,
23
+ ConnectionPortal,
23
24
 
24
25
  // Popup screens (small overlays)
25
26
  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 | Screen::AgentList
35
+ Screen::AgentBuilder | Screen::RunManager | Screen::Settings | Screen::AgentList | Screen::ConnectionPortal
35
36
  )
36
37
  }
37
38
 
@@ -58,6 +59,7 @@ impl Screen {
58
59
  Screen::RunManager => "Run Manager",
59
60
  Screen::Settings => "Settings",
60
61
  Screen::AgentList => "Agent List",
62
+ Screen::ConnectionPortal => "Connection Portal",
61
63
  Screen::Confirmation { .. } => "Confirmation",
62
64
  Screen::Alert { .. } => "Alert",
63
65
  }
@@ -120,6 +120,8 @@ fn render_list_view(f: &mut Frame, area: Rect, state: &AppState) {
120
120
  Span::styled(" Navigate | ", Style::default().fg(TEXT_DIM)),
121
121
  Span::styled("Enter", Style::default().fg(NEON_GREEN)),
122
122
  Span::styled(" View Details | ", Style::default().fg(TEXT_DIM)),
123
+ Span::styled("D/Del", Style::default().fg(Color::Rgb(255, 100, 100))),
124
+ Span::styled(" Delete | ", Style::default().fg(TEXT_DIM)),
123
125
  Span::styled("ESC", Style::default().fg(BRAND_PURPLE)),
124
126
  Span::styled(" Close", Style::default().fg(TEXT_DIM)),
125
127
  ]);
@@ -0,0 +1,232 @@
1
+ /// Connection Portal Screen
2
+ /// UI for connecting to Gateway server and switching to Connected mode
3
+
4
+ use ratatui::prelude::*;
5
+ use ratatui::widgets::{Block, Borders, Paragraph, Wrap, Clear};
6
+ use crate::app::{AppState, PortalField};
7
+
8
+ // === 4RUNR BRAND COLORS (matching layout.rs) ===
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
+ const ERROR_RED: Color = Color::Rgb(255, 69, 69);
18
+
19
+ /// Render the Connection Portal screen (full-screen overlay)
20
+ pub fn render(f: &mut Frame, state: &AppState) {
21
+ let area = f.size();
22
+
23
+ // Clear the entire screen for modal effect
24
+ f.render_widget(Clear, area);
25
+
26
+ // Center the portal dialog
27
+ let portal_width = area.width.min(70);
28
+ let portal_height = area.height.min(25);
29
+ let portal_x = (area.width.saturating_sub(portal_width)) / 2;
30
+ let portal_y = (area.height.saturating_sub(portal_height)) / 2;
31
+
32
+ let portal_area = Rect {
33
+ x: portal_x,
34
+ y: portal_y,
35
+ width: portal_width,
36
+ height: portal_height,
37
+ };
38
+
39
+ // Split portal into sections
40
+ use ratatui::layout::{Constraint, Direction, Layout};
41
+
42
+ let chunks = Layout::default()
43
+ .direction(Direction::Vertical)
44
+ .constraints([
45
+ Constraint::Length(3), // Header
46
+ Constraint::Length(4), // Description
47
+ Constraint::Length(7), // Input fields
48
+ Constraint::Length(3), // Status
49
+ Constraint::Min(1), // Spacer
50
+ Constraint::Length(3), // Actions
51
+ ])
52
+ .split(portal_area);
53
+
54
+ render_header(f, chunks[0], state);
55
+ render_description(f, chunks[1]);
56
+ render_input_fields(f, chunks[2], state);
57
+ render_status(f, chunks[3], state);
58
+ render_actions(f, chunks[5], state);
59
+ }
60
+
61
+ fn render_header(f: &mut Frame, area: Rect, state: &AppState) {
62
+ let title = if state.connection_portal.connecting {
63
+ " 🔌 Connection Portal - Connecting... "
64
+ } else {
65
+ " 🔌 Connection Portal "
66
+ };
67
+
68
+ let block = Block::default()
69
+ .title(title)
70
+ .borders(Borders::ALL)
71
+ .border_style(Style::default().fg(if state.connection_portal.connecting {
72
+ AMBER_WARN
73
+ } else if state.connection_portal.error.is_some() {
74
+ ERROR_RED
75
+ } else {
76
+ BRAND_PURPLE
77
+ }))
78
+ .style(Style::default().bg(BG_PANEL));
79
+
80
+ f.render_widget(block, area);
81
+ }
82
+
83
+ fn render_description(f: &mut Frame, area: Rect) {
84
+ let block = Block::default()
85
+ .borders(Borders::LEFT | Borders::RIGHT)
86
+ .style(Style::default().bg(BG_PANEL));
87
+
88
+ let inner = block.inner(area);
89
+ f.render_widget(block, area);
90
+
91
+ let text = vec![
92
+ Line::from(Span::styled(
93
+ "Connect to Gateway Server to enable full OS features including",
94
+ Style::default().fg(TEXT_PRIMARY)
95
+ )),
96
+ Line::from(Span::styled(
97
+ "Shield, Sentinel, and server tools.",
98
+ Style::default().fg(TEXT_PRIMARY)
99
+ )),
100
+ ];
101
+
102
+ let paragraph = Paragraph::new(text)
103
+ .wrap(Wrap { trim: false })
104
+ .alignment(Alignment::Center);
105
+
106
+ f.render_widget(paragraph, inner);
107
+ }
108
+
109
+ fn render_input_fields(f: &mut Frame, area: Rect, state: &AppState) {
110
+ let block = Block::default()
111
+ .borders(Borders::LEFT | Borders::RIGHT)
112
+ .style(Style::default().bg(BG_PANEL));
113
+
114
+ let inner = block.inner(area);
115
+ f.render_widget(block, area);
116
+
117
+ let portal = &state.connection_portal;
118
+ let url_focused = portal.focused_field == PortalField::GatewayUrl;
119
+ let username_focused = portal.focused_field == PortalField::Username;
120
+
121
+ // Disable input if connecting
122
+ let input_enabled = !portal.connecting;
123
+
124
+ let text = vec![
125
+ Line::from(""),
126
+ // Gateway URL field
127
+ Line::from(vec![
128
+ Span::styled(" ", Style::default()),
129
+ Span::styled(
130
+ if url_focused && input_enabled { "▶ " } else { " " },
131
+ Style::default().fg(BRAND_PURPLE).bold()
132
+ ),
133
+ Span::styled("Gateway URL:", Style::default().fg(TEXT_DIM)),
134
+ ]),
135
+ Line::from(vec![
136
+ Span::styled(" ", Style::default()),
137
+ Span::styled(
138
+ format!("[{}]", portal.gateway_url),
139
+ Style::default()
140
+ .fg(if url_focused && input_enabled { CYBER_CYAN } else { TEXT_PRIMARY })
141
+ .bold()
142
+ ),
143
+ ]),
144
+ Line::from(""),
145
+ // Username field
146
+ Line::from(vec![
147
+ Span::styled(" ", Style::default()),
148
+ Span::styled(
149
+ if username_focused && input_enabled { "▶ " } else { " " },
150
+ Style::default().fg(BRAND_PURPLE).bold()
151
+ ),
152
+ Span::styled("Username (optional):", Style::default().fg(TEXT_DIM)),
153
+ ]),
154
+ Line::from(vec![
155
+ Span::styled(" ", Style::default()),
156
+ Span::styled(
157
+ format!("[{}]", if portal.username.is_empty() { " ".to_string() } else { portal.username.clone() }),
158
+ Style::default()
159
+ .fg(if username_focused && input_enabled { CYBER_CYAN } else { TEXT_MUTED })
160
+ ),
161
+ ]),
162
+ ];
163
+
164
+ let paragraph = Paragraph::new(text)
165
+ .wrap(Wrap { trim: false })
166
+ .alignment(Alignment::Left);
167
+
168
+ f.render_widget(paragraph, inner);
169
+ }
170
+
171
+ fn render_status(f: &mut Frame, area: Rect, state: &AppState) {
172
+ let block = Block::default()
173
+ .borders(Borders::LEFT | Borders::RIGHT)
174
+ .style(Style::default().bg(BG_PANEL));
175
+
176
+ let inner = block.inner(area);
177
+ f.render_widget(block, area);
178
+
179
+ let portal = &state.connection_portal;
180
+
181
+ let (status_text, status_color) = if let Some(error) = &portal.error {
182
+ (format!("Connection failed: {}", error), ERROR_RED)
183
+ } else if portal.connecting {
184
+ ("Connecting...".to_string(), AMBER_WARN)
185
+ } else {
186
+ ("Ready to connect".to_string(), TEXT_PRIMARY)
187
+ };
188
+
189
+ let text = Line::from(vec![
190
+ Span::styled(" Status: ", Style::default().fg(TEXT_DIM)),
191
+ Span::styled(status_text, Style::default().fg(status_color).bold()),
192
+ ]);
193
+
194
+ let paragraph = Paragraph::new(text)
195
+ .alignment(Alignment::Left);
196
+
197
+ f.render_widget(paragraph, inner);
198
+ }
199
+
200
+ fn render_actions(f: &mut Frame, area: Rect, state: &AppState) {
201
+ let block = Block::default()
202
+ .title(" ⌨️ Actions ")
203
+ .borders(Borders::ALL)
204
+ .border_style(Style::default().fg(TEXT_DIM))
205
+ .style(Style::default().bg(BG_PANEL));
206
+
207
+ let inner = block.inner(area);
208
+ f.render_widget(block, area);
209
+
210
+ let portal = &state.connection_portal;
211
+
212
+ let text = if portal.connecting {
213
+ Line::from(vec![
214
+ Span::styled("Connecting to Gateway server...", Style::default().fg(AMBER_WARN)),
215
+ ])
216
+ } else {
217
+ Line::from(vec![
218
+ Span::styled("Tab", Style::default().fg(CYBER_CYAN).bold()),
219
+ Span::styled(" Switch Field │ ", Style::default().fg(TEXT_DIM)),
220
+ Span::styled("Enter", Style::default().fg(NEON_GREEN).bold()),
221
+ Span::styled(" Connect │ ", Style::default().fg(TEXT_DIM)),
222
+ Span::styled("ESC", Style::default().fg(BRAND_PURPLE).bold()),
223
+ Span::styled(" Cancel", Style::default().fg(TEXT_DIM)),
224
+ ])
225
+ };
226
+
227
+ let paragraph = Paragraph::new(text)
228
+ .style(Style::default().fg(TEXT_PRIMARY))
229
+ .alignment(Alignment::Center);
230
+
231
+ f.render_widget(paragraph, inner);
232
+ }
@@ -118,13 +118,22 @@ fn render_header(f: &mut Frame, area: Rect, state: &AppState) {
118
118
  // Format uptime
119
119
  let uptime_str = format_uptime(state.uptime_secs);
120
120
 
121
- // Line 1: Brand + version + uptime - Bug 3 fix: Use "4Runr." with dot (matches brand logo)
122
- // Use npm package version (2.3.5) - matches package.json
123
- const PACKAGE_VERSION: &str = "2.6.11";
121
+ // Operation mode indicator (Step 7)
122
+ let mode_text = state.operation_mode.as_str();
123
+ let mode_color = match state.operation_mode {
124
+ crate::app::OperationMode::Local => AMBER_WARN,
125
+ crate::app::OperationMode::Connected => NEON_GREEN,
126
+ };
127
+
128
+ // Line 1: Brand + version + mode + uptime - Bug 3 fix: Use "4Runr." with dot (matches brand logo)
129
+ // Use npm package version (2.7.0) - matches package.json
130
+ const PACKAGE_VERSION: &str = "2.8.0";
124
131
  let brand_line = Line::from(vec![
125
132
  Span::styled("4Runr.", Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
126
133
  Span::styled(" AI AGENT OS", Style::default().fg(BRAND_VIOLET)),
127
134
  Span::styled(format!(" v{}", PACKAGE_VERSION), Style::default().fg(TEXT_MUTED)),
135
+ Span::styled(" Mode: ", Style::default().fg(TEXT_MUTED)),
136
+ Span::styled(mode_text, Style::default().fg(mode_color).add_modifier(Modifier::BOLD)),
128
137
  Span::styled(format!(" {} UPTIME: {}", spinner, uptime_str),
129
138
  Style::default().fg(TEXT_MUTED)),
130
139
  ]);
@@ -1,6 +1,7 @@
1
1
  pub mod agent_builder;
2
2
  pub mod agent_list;
3
3
  pub mod boot;
4
+ pub mod connection_portal;
4
5
  pub mod help;
5
6
  pub mod layout;
6
7
  pub mod run_manager;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "4runr-os",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "type": "module",
5
- "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.7.0: Step 5 Complete - Full agent management integration. Create, view, select, and delete agents through TUI with real-time backend sync. Agent Builder, Agent List, and Agent Details fully functional. Built with Rust + Ratatui. ⚠️ Pre-MVP / Development Phase",
5
+ "description": "4Runr AI Agent OS - Secure terminal interface for AI agents. v2.8.0: Step 5 & 7 Complete - Full agent management integration + Connection Portal. Create, view, select, and delete agents through TUI. Dynamic mode switching (Local ↔ Connected) with Gateway connection. Agent Builder, Agent List, Connection Portal fully functional. Built with Rust + Ratatui. ⚠️ Pre-MVP / Development Phase",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "4runr": "dist/index.js",