4runr-os-mk3 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml ADDED
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "mk3-tui"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ ratatui = "0.27"
8
+ crossterm = "0.28"
9
+ tokio = { version = "1", features = ["full"] }
10
+ serde = { version = "1", features = ["derive"] }
11
+ serde_json = "1"
12
+ tokio-tungstenite = "0.24"
13
+ tungstenite = "0.24"
14
+ anyhow = "1"
15
+ thiserror = "2"
16
+
package/bin/mk3-tui.js CHANGED
@@ -10,18 +10,32 @@ const __dirname = dirname(__filename);
10
10
 
11
11
  // Try to find the binary in different locations
12
12
  const possiblePaths = [
13
- // Development build
13
+ // From npm package (installed)
14
14
  join(__dirname, '..', 'target', 'release', 'mk3-tui'),
15
15
  join(__dirname, '..', 'target', 'release', 'mk3-tui.exe'),
16
- // Installed binary
17
- join(__dirname, 'mk3-tui'),
18
- join(__dirname, 'mk3-tui.exe'),
16
+ // From global npm install (in PATH)
17
+ process.platform === 'win32' ? 'mk3-tui.exe' : 'mk3-tui',
18
+ // Development build (local)
19
+ join(__dirname, '..', '..', '..', 'apps', 'mk3-tui', 'target', 'release', 'mk3-tui'),
20
+ join(__dirname, '..', '..', '..', 'apps', 'mk3-tui', 'target', 'release', 'mk3-tui.exe'),
19
21
  ];
20
22
 
21
23
  let binaryPath = null;
22
- for (const path of possiblePaths) {
23
- if (existsSync(path)) {
24
- binaryPath = path;
24
+ for (const testPath of possiblePaths) {
25
+ // Check if it's a PATH command (string without slashes)
26
+ if (!testPath.includes('/') && !testPath.includes('\\')) {
27
+ // Try to find in PATH
28
+ try {
29
+ const { execSync } = await import('child_process');
30
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
31
+ execSync(`${whichCmd} ${testPath}`, { stdio: 'ignore' });
32
+ binaryPath = testPath;
33
+ break;
34
+ } catch {
35
+ continue;
36
+ }
37
+ } else if (existsSync(testPath)) {
38
+ binaryPath = testPath;
25
39
  break;
26
40
  }
27
41
  }
package/install.js CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Post-install script for 4runr-os-mk3
5
+ * Attempts to build the binary if Rust is available and binary is missing
6
+ */
7
+
3
8
  import { execSync } from 'child_process';
4
9
  import { existsSync } from 'fs';
5
10
  import { join, dirname } from 'path';
@@ -9,21 +14,42 @@ const __filename = fileURLToPath(import.meta.url);
9
14
  const __dirname = dirname(__filename);
10
15
 
11
16
  const binaryPath = join(__dirname, 'target', 'release', process.platform === 'win32' ? 'mk3-tui.exe' : 'mk3-tui');
17
+ const srcPath = join(__dirname, 'src');
12
18
 
13
- // Check if binary exists, if not, try to build it
14
- if (!existsSync(binaryPath)) {
15
- console.log('mk3-tui binary not found. Building...');
16
- try {
17
- execSync('cargo build --release', {
18
- cwd: __dirname,
19
- stdio: 'inherit',
20
- });
21
- console.log('Build complete!');
22
- } catch (error) {
23
- console.error('Failed to build mk3-tui. Make sure Rust is installed: https://rustup.rs/');
24
- process.exit(1);
25
- }
19
+ // Check if binary exists
20
+ if (existsSync(binaryPath)) {
21
+ console.log('mk3-tui binary found.');
26
22
  } else {
27
- console.log('mk3-tui binary found.');
23
+ // Check if Rust source code is available (from npm package)
24
+ if (existsSync(join(__dirname, 'Cargo.toml')) && existsSync(srcPath)) {
25
+ // Check if Rust is installed
26
+ try {
27
+ execSync('cargo --version', { stdio: 'ignore' });
28
+ console.log('🔨 Building mk3-tui from source (Rust detected)...');
29
+
30
+ try {
31
+ execSync('cargo build --release', {
32
+ cwd: __dirname,
33
+ stdio: 'inherit',
34
+ });
35
+ console.log('✅ Build complete!');
36
+ } catch (buildError) {
37
+ console.warn('⚠️ Build failed. The binary will need to be built manually.');
38
+ console.warn(' Make sure Rust is properly installed: https://rustup.rs/');
39
+ // Don't exit - let the package install, user can build manually later
40
+ }
41
+ } catch {
42
+ // Rust not installed
43
+ console.warn('⚠️ mk3-tui binary not found and Rust is not installed.');
44
+ console.warn(' To build from source, install Rust: https://rustup.rs/');
45
+ console.warn(' Then run: cd node_modules/4runr-os-mk3 && cargo build --release');
46
+ // Don't exit - let the package install
47
+ }
48
+ } else {
49
+ // Source code not available
50
+ console.warn('⚠️ Warning: mk3-tui binary not found.');
51
+ console.warn(' The binary should be included in the npm package.');
52
+ console.warn(' If you need to build it manually, install Rust: https://rustup.rs/');
53
+ }
28
54
  }
29
55
 
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "4runr-os-mk3",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "4Runr AI Agent OS - Modern Terminal UI (Rust + Ratatui)",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
- "mk3-tui": "./bin/mk3-tui.js"
8
+ "mk3-tui": "bin/mk3-tui.js"
9
9
  },
10
10
  "scripts": {
11
11
  "build": "cargo build --release",
12
- "install": "node install.js",
12
+ "postinstall": "node install.js",
13
13
  "prepublishOnly": "npm run build"
14
14
  },
15
15
  "files": [
16
16
  "bin/**/*",
17
+ "src/**/*",
18
+ "Cargo.toml",
19
+ "Cargo.lock",
17
20
  "target/release/mk3-tui*",
18
21
  "install.js",
19
22
  "README.md",
@@ -0,0 +1,103 @@
1
+ use std::time::{Duration, Instant};
2
+
3
+ #[derive(Debug, Clone, Copy, PartialEq)]
4
+ pub enum RunMode {
5
+ Local,
6
+ Browser,
7
+ }
8
+
9
+ impl RunMode {
10
+ pub fn detect() -> Self {
11
+ if let Ok(mode) = std::env::var("RUN_MODE") {
12
+ match mode.to_lowercase().as_str() {
13
+ "browser" | "web" => return RunMode::Browser,
14
+ "local" => return RunMode::Local,
15
+ _ => {}
16
+ }
17
+ }
18
+
19
+ // Auto-detect
20
+ if std::env::var("AWS_EXECUTION_ENV").is_ok()
21
+ || std::env::var("CLOUDSHELL").is_ok()
22
+ || std::env::var("TUI_WEB").map(|v| v == "1").unwrap_or(false)
23
+ {
24
+ RunMode::Browser
25
+ } else {
26
+ RunMode::Local
27
+ }
28
+ }
29
+ }
30
+
31
+ pub struct RenderScheduler {
32
+ run_mode: RunMode,
33
+ last_render: Instant,
34
+ render_scheduled: bool,
35
+ render_scheduled_count: u64,
36
+ min_render_interval: Duration,
37
+ }
38
+
39
+ impl RenderScheduler {
40
+ pub fn new() -> Self {
41
+ let run_mode = RunMode::detect();
42
+ let min_render_interval = match run_mode {
43
+ RunMode::Browser => Duration::from_millis(100), // Max 10 FPS
44
+ RunMode::Local => Duration::from_millis(50), // Max 20 FPS
45
+ };
46
+
47
+ Self {
48
+ run_mode,
49
+ last_render: Instant::now(),
50
+ render_scheduled: false,
51
+ render_scheduled_count: 0,
52
+ min_render_interval,
53
+ }
54
+ }
55
+
56
+ pub fn run_mode(&self) -> RunMode {
57
+ self.run_mode
58
+ }
59
+
60
+ pub fn request_render(&mut self, _reason: &str) -> bool {
61
+ self.render_scheduled_count += 1;
62
+
63
+ let now = Instant::now();
64
+ let elapsed = now.duration_since(self.last_render);
65
+
66
+ if elapsed >= self.min_render_interval {
67
+ // Can render immediately
68
+ self.last_render = now;
69
+ self.render_scheduled = false;
70
+ true
71
+ } else {
72
+ // Schedule for later
73
+ self.render_scheduled = true;
74
+ false
75
+ }
76
+ }
77
+
78
+ pub fn should_render(&mut self) -> bool {
79
+ if !self.render_scheduled {
80
+ return false;
81
+ }
82
+
83
+ let now = Instant::now();
84
+ let elapsed = now.duration_since(self.last_render);
85
+
86
+ if elapsed >= self.min_render_interval {
87
+ self.last_render = now;
88
+ self.render_scheduled = false;
89
+ true
90
+ } else {
91
+ false
92
+ }
93
+ }
94
+
95
+ pub fn render_scheduled_count(&self) -> u64 {
96
+ self.render_scheduled_count
97
+ }
98
+
99
+ pub fn min_render_interval_ms(&self) -> u64 {
100
+ self.min_render_interval.as_millis() as u64
101
+ }
102
+ }
103
+
package/src/app.rs ADDED
@@ -0,0 +1,435 @@
1
+ use crate::io::IoHandler;
2
+ use crossterm::event::{KeyEvent, KeyCode};
3
+ use ratatui::prelude::*;
4
+ use serde::{Deserialize, Serialize};
5
+ use std::collections::VecDeque;
6
+ use std::time::{Duration, Instant};
7
+
8
+ mod render_scheduler;
9
+ pub use render_scheduler::{RenderScheduler, RunMode};
10
+
11
+ #[derive(Debug, Clone, Serialize, Deserialize)]
12
+ pub enum Message {
13
+ Log { message: String, ts: String },
14
+ Posture { status: String },
15
+ Metrics { cpu: f64, mem: f64 },
16
+ Network { status: String },
17
+ Capabilities { items: Vec<String> },
18
+ }
19
+
20
+ #[derive(Debug, Clone, PartialEq)]
21
+ pub enum AppMode {
22
+ Boot,
23
+ Main,
24
+ }
25
+
26
+ #[derive(Debug, Clone)]
27
+ pub struct AppState {
28
+ // App mode
29
+ pub mode: AppMode,
30
+
31
+ // Boot state
32
+ pub boot_progress: u16, // 0-100
33
+ pub boot_lines: VecDeque<String>,
34
+ pub boot_done: bool,
35
+ pub boot_started_at: Instant,
36
+ // Connection state
37
+ pub connected: bool,
38
+ #[allow(dead_code)]
39
+ pub gateway_url: Option<String>,
40
+
41
+ // Real component status
42
+ #[allow(dead_code)]
43
+ pub gateway_healthy: bool,
44
+ pub posture_status: String,
45
+ pub shield_mode: String, // "off", "monitor", "enforce"
46
+ pub shield_detectors: Vec<String>, // ["pii", "injection", "hallucination"]
47
+ pub sentinel_state: String, // "idle", "watching", "triggered"
48
+ pub sentinel_active_runs: usize,
49
+
50
+ // Real metrics
51
+ pub total_runs: u64,
52
+ #[allow(dead_code)]
53
+ pub active_sse_connections: u64,
54
+ #[allow(dead_code)]
55
+ pub idempotency_store_size: u64,
56
+
57
+ // System resources
58
+ pub cpu: f64,
59
+ pub mem: f64,
60
+ pub network_status: String,
61
+
62
+ // Real logs
63
+ pub logs: VecDeque<String>,
64
+
65
+ // Capabilities
66
+ pub capabilities: Vec<String>,
67
+
68
+ // UI state
69
+ pub command_input: String,
70
+ pub command_focused: bool,
71
+ pub log_scroll: usize,
72
+
73
+ // Animation state
74
+ pub tick: u64,
75
+ pub spinner_frame: usize,
76
+ pub uptime_secs: u64, // Updated from real clock in main loop, not from tick()
77
+
78
+ // Perf overlay
79
+ pub perf_overlay: bool,
80
+ pub render_count: u64,
81
+ pub last_render_time: std::time::Instant,
82
+ pub render_durations: VecDeque<u64>, // ms
83
+ pub render_scheduled_count: u64,
84
+ pub log_write_count: u64,
85
+ }
86
+
87
+ impl Default for AppState {
88
+ fn default() -> Self {
89
+ Self {
90
+ mode: AppMode::Boot,
91
+ boot_progress: 0,
92
+ boot_lines: VecDeque::new(),
93
+ boot_done: false,
94
+ boot_started_at: Instant::now(),
95
+ connected: false,
96
+ gateway_url: None,
97
+ gateway_healthy: false,
98
+ posture_status: "Demo Mode".to_string(),
99
+ shield_mode: "enforce".to_string(),
100
+ shield_detectors: vec!["pii".into(), "injection".into(), "hallucination".into()],
101
+ sentinel_state: "idle".to_string(),
102
+ sentinel_active_runs: 0,
103
+ total_runs: 0,
104
+ active_sse_connections: 0,
105
+ idempotency_store_size: 0,
106
+ cpu: 0.15,
107
+ mem: 0.25,
108
+ network_status: "Not Connected".to_string(),
109
+ logs: VecDeque::from([
110
+ "[SYSTEM] 4Runr AI Agent OS initialized".to_string(),
111
+ "[SHIELD] Safety layer active (enforce mode)".to_string(),
112
+ "[SHIELD] Detectors: PII, Injection, Hallucination".to_string(),
113
+ "[SENTINEL] Real-time monitoring ready".to_string(),
114
+ "[GATEWAY] Not connected - run with --gateway to connect".to_string(),
115
+ "[SYSTEM] Type 'help' for available commands".to_string(),
116
+ ]),
117
+ capabilities: vec![
118
+ "text-analysis".to_string(),
119
+ "code-review".to_string(),
120
+ "data-extraction".to_string(),
121
+ ],
122
+ command_input: String::new(),
123
+ command_focused: false,
124
+ log_scroll: 0,
125
+ tick: 0,
126
+ spinner_frame: 0,
127
+ uptime_secs: 0,
128
+ perf_overlay: false,
129
+ render_count: 0,
130
+ last_render_time: std::time::Instant::now(),
131
+ render_durations: VecDeque::with_capacity(60),
132
+ render_scheduled_count: 0,
133
+ log_write_count: 0,
134
+ }
135
+ }
136
+ }
137
+
138
+ pub struct App {
139
+ pub(crate) state: AppState,
140
+ render_scheduler: RenderScheduler,
141
+ pub(crate) input_debounce: Option<Instant>,
142
+ pub(crate) input_debounce_duration: Duration,
143
+ }
144
+
145
+ impl App {
146
+ pub fn new() -> Self {
147
+ let render_scheduler = RenderScheduler::new();
148
+ let run_mode = render_scheduler.run_mode();
149
+
150
+ // Input debounce: browser 50ms, local 25ms
151
+ let input_debounce_duration = match run_mode {
152
+ RunMode::Browser => Duration::from_millis(50),
153
+ RunMode::Local => Duration::from_millis(25),
154
+ };
155
+
156
+ Self {
157
+ state: AppState::default(),
158
+ render_scheduler,
159
+ input_debounce: None,
160
+ input_debounce_duration,
161
+ }
162
+ }
163
+
164
+ pub fn request_render(&mut self, reason: &str) -> bool {
165
+ self.render_scheduler.request_render(reason)
166
+ }
167
+
168
+ pub fn should_render(&mut self) -> bool {
169
+ self.render_scheduler.should_render()
170
+ }
171
+
172
+ pub fn record_render(&mut self, duration_ms: u64) {
173
+ self.state.render_count += 1;
174
+ self.state.render_durations.push_back(duration_ms);
175
+ if self.state.render_durations.len() > 60 {
176
+ self.state.render_durations.pop_front();
177
+ }
178
+ self.state.last_render_time = Instant::now();
179
+ }
180
+
181
+ pub fn run_mode(&self) -> RunMode {
182
+ self.render_scheduler.run_mode()
183
+ }
184
+
185
+ pub fn render_scheduler_stats(&self) -> (u64, u64) {
186
+ (
187
+ self.render_scheduler.render_scheduled_count(),
188
+ self.render_scheduler.min_render_interval_ms(),
189
+ )
190
+ }
191
+
192
+ /// IMPORTANT: Only call tick() when poll() times out (NO keyboard input)!
193
+ /// Otherwise animations will flash weirdly when typing.
194
+ ///
195
+ /// NOTE: Uptime is now tracked via Instant::now() in main loop,
196
+ /// so we don't increment it here anymore.
197
+ pub fn tick(&mut self) {
198
+ self.state.tick = self.state.tick.wrapping_add(1);
199
+
200
+ // Update spinner frame (for "Processing..." indicator only)
201
+ // Cycle through 8 braille spinner frames
202
+ self.state.spinner_frame = (self.state.spinner_frame + 1) % 8;
203
+
204
+ // Boot timeline: update progress every ~150-250ms
205
+ if self.state.mode == AppMode::Boot && !self.state.boot_done {
206
+ let elapsed = self.state.boot_started_at.elapsed();
207
+ let boot_steps = vec![
208
+ (Duration::from_millis(200), "Initializing gateway…"),
209
+ (Duration::from_millis(500), "Loading policies…"),
210
+ (Duration::from_millis(800), "Agent registry online…"),
211
+ (Duration::from_millis(1100), "Telemetry connected…"),
212
+ (Duration::from_millis(1400), "System ready."),
213
+ ];
214
+
215
+ let mut current_progress = 0;
216
+ let mut step_idx = 0;
217
+
218
+ for (i, (delay, msg)) in boot_steps.iter().enumerate() {
219
+ if elapsed >= *delay {
220
+ step_idx = i + 1;
221
+ current_progress = ((i + 1) * 100 / boot_steps.len()) as u16;
222
+
223
+ // Add line if not already added
224
+ if self.state.boot_lines.len() <= i {
225
+ self.state.boot_lines.push_back(msg.to_string());
226
+ self.request_render("boot_line");
227
+ }
228
+ }
229
+ }
230
+
231
+ self.state.boot_progress = current_progress.min(100);
232
+
233
+ // Mark boot done after last step
234
+ if step_idx >= boot_steps.len() && self.state.boot_progress >= 100 {
235
+ self.state.boot_done = true;
236
+ self.request_render("boot_done");
237
+ }
238
+ }
239
+
240
+ // NOTE: pulse_frame is NOT updated here - we use static dots for status indicators
241
+ // to prevent flashing when typing. Only use animated pulse for non-status elements.
242
+ }
243
+
244
+ pub fn handle_input(&mut self, key: KeyEvent, _io: &mut IoHandler) -> anyhow::Result<bool> {
245
+ use crossterm::event::KeyModifiers;
246
+
247
+ // === EXIT SHORTCUTS (always checked first) ===
248
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
249
+ match key.code {
250
+ KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('d') => {
251
+ return Ok(true); // Exit
252
+ }
253
+ _ => return Ok(false), // Ignore other Ctrl combos
254
+ }
255
+ }
256
+
257
+ if key.code == KeyCode::F(10) {
258
+ return Ok(true); // Exit
259
+ }
260
+
261
+ // === BOOT MODE: Any key to continue ===
262
+ if self.state.mode == AppMode::Boot && self.state.boot_done {
263
+ // Any key press switches to main dashboard
264
+ self.state.mode = AppMode::Main;
265
+ self.request_render("boot_to_main");
266
+ return Ok(false);
267
+ }
268
+
269
+ // In boot mode, ignore other input until boot is done
270
+ if self.state.mode == AppMode::Boot {
271
+ return Ok(false);
272
+ }
273
+
274
+ // F12 - Toggle perf overlay
275
+ if key.code == KeyCode::F(12) {
276
+ self.state.perf_overlay = !self.state.perf_overlay;
277
+ self.request_render("perf_toggle");
278
+ return Ok(false);
279
+ }
280
+
281
+ // === MAIN INPUT HANDLING ===
282
+ match key.code {
283
+ // Typing - ALL characters go to command input (with debounce)
284
+ KeyCode::Char(c) => {
285
+ self.state.command_input.push(c);
286
+ self.state.command_focused = true;
287
+ // Debounce: schedule render after delay
288
+ self.input_debounce = Some(Instant::now());
289
+ // Don't render immediately - let debounce handle it
290
+ }
291
+
292
+ // Submit command
293
+ KeyCode::Enter => {
294
+ if !self.state.command_input.is_empty() {
295
+ let cmd: String = self.state.command_input.drain(..).collect();
296
+ self.state.command_focused = false;
297
+ self.input_debounce = None;
298
+
299
+ match cmd.to_lowercase().as_str() {
300
+ "quit" | "exit" => return Ok(true),
301
+ "clear" => self.state.logs.clear(),
302
+ "help" => {
303
+ self.state.logs.push_back("[HELP] Commands: quit, exit, clear, help, :perf".into());
304
+ }
305
+ ":perf" => {
306
+ // Perf self-check command
307
+ let rps = if !self.state.render_durations.is_empty() {
308
+ let avg_ms: f64 = self.state.render_durations.iter().sum::<u64>() as f64
309
+ / self.state.render_durations.len() as f64;
310
+ if avg_ms > 0.0 {
311
+ (1000.0 / avg_ms) as u64
312
+ } else {
313
+ 0
314
+ }
315
+ } else {
316
+ 0
317
+ };
318
+
319
+ let mode_str = match self.run_mode() {
320
+ RunMode::Browser => "browser",
321
+ RunMode::Local => "local",
322
+ };
323
+
324
+ let (scheduled_count, interval_ms) = self.render_scheduler_stats();
325
+
326
+ self.state.logs.push_back(format!(
327
+ "[PERF] Mode: {} | RPS: {} | Render interval: {}ms | Scheduled: {}",
328
+ mode_str,
329
+ rps,
330
+ interval_ms,
331
+ scheduled_count
332
+ ));
333
+ }
334
+ _ => {
335
+ self.state.logs.push_back(format!("> {}", cmd));
336
+ }
337
+ }
338
+ self.request_render("command_submit");
339
+ }
340
+ }
341
+
342
+ // Delete character
343
+ KeyCode::Backspace => {
344
+ self.state.command_input.pop();
345
+ if self.state.command_input.is_empty() {
346
+ self.state.command_focused = false;
347
+ }
348
+ // Debounce render
349
+ self.input_debounce = Some(Instant::now());
350
+ }
351
+
352
+ // Clear input
353
+ KeyCode::Esc => {
354
+ self.state.command_input.clear();
355
+ self.state.command_focused = false;
356
+ self.input_debounce = None;
357
+ self.request_render("clear_input");
358
+ }
359
+
360
+ // === SCROLL: Arrow keys (only when not typing) ===
361
+ // Calculate max valid scroll based on visible height
362
+ KeyCode::Up if self.state.command_input.is_empty() => {
363
+ let visible_height = 15; // Approximate visible log height (adjust based on actual panel size)
364
+ let total_logs = self.state.logs.len();
365
+ let max_scroll = total_logs.saturating_sub(visible_height.max(1));
366
+
367
+ // Clamp scroll to valid range [0, max_scroll]
368
+ self.state.log_scroll = (self.state.log_scroll + 1).min(max_scroll);
369
+ self.request_render("scroll_up");
370
+ }
371
+
372
+ KeyCode::Down if self.state.command_input.is_empty() => {
373
+ self.state.log_scroll = self.state.log_scroll.saturating_sub(1);
374
+ self.request_render("scroll_down");
375
+ }
376
+
377
+ KeyCode::PageUp if self.state.command_input.is_empty() => {
378
+ let visible_height = 15;
379
+ let total_logs = self.state.logs.len();
380
+ let max_scroll = total_logs.saturating_sub(visible_height.max(1));
381
+
382
+ self.state.log_scroll = (self.state.log_scroll + 10).min(max_scroll);
383
+ self.request_render("scroll_page_up");
384
+ }
385
+
386
+ KeyCode::PageDown if self.state.command_input.is_empty() => {
387
+ self.state.log_scroll = self.state.log_scroll.saturating_sub(10);
388
+ self.request_render("scroll_page_down");
389
+ }
390
+
391
+ _ => {}
392
+ }
393
+
394
+ Ok(false)
395
+ }
396
+
397
+ pub fn update(&mut self, message: Message) {
398
+ match message {
399
+ Message::Log { message, ts } => {
400
+ let log_line = format!("[{}] {}", ts, message);
401
+ self.state.logs.push_back(log_line);
402
+ if self.state.logs.len() > 1000 {
403
+ self.state.logs.pop_front();
404
+ }
405
+ }
406
+ Message::Posture { status } => {
407
+ self.state.posture_status = status;
408
+ }
409
+ Message::Metrics { cpu, mem } => {
410
+ self.state.cpu = cpu;
411
+ self.state.mem = mem;
412
+ }
413
+ Message::Network { status } => {
414
+ self.state.network_status = status;
415
+ }
416
+ Message::Capabilities { items } => {
417
+ self.state.capabilities = items;
418
+ }
419
+ }
420
+ }
421
+
422
+ pub fn render(&self, f: &mut Frame) {
423
+ match self.state.mode {
424
+ AppMode::Boot => {
425
+ use crate::ui::boot;
426
+ boot::render_boot(f, f.size(), &self.state);
427
+ }
428
+ AppMode::Main => {
429
+ use crate::ui::layout;
430
+ layout::render(f, &self.state);
431
+ }
432
+ }
433
+ }
434
+ }
435
+