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.lock +1105 -0
- package/Cargo.toml +16 -0
- package/bin/mk3-tui.js +21 -7
- package/install.js +40 -14
- package/package.json +6 -3
- package/src/app/render_scheduler.rs +103 -0
- package/src/app.rs +435 -0
- package/src/io/mod.rs +66 -0
- package/src/io/protocol.rs +15 -0
- package/src/io/stdio.rs +32 -0
- package/src/io/ws.rs +32 -0
- package/src/main.rs +119 -0
- package/src/ui/boot.rs +150 -0
- package/src/ui/layout.rs +705 -0
- package/src/ui/mod.rs +4 -0
- package/src/ui/safe_viewport.rs +235 -0
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
|
-
//
|
|
13
|
+
// From npm package (installed)
|
|
14
14
|
join(__dirname, '..', 'target', 'release', 'mk3-tui'),
|
|
15
15
|
join(__dirname, '..', 'target', 'release', 'mk3-tui.exe'),
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
23
|
-
if (
|
|
24
|
-
|
|
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
|
|
14
|
-
if (
|
|
15
|
-
console.log('mk3-tui binary
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
8
|
+
"mk3-tui": "bin/mk3-tui.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "cargo build --release",
|
|
12
|
-
"
|
|
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
|
+
|