4runr-os 2.1.21 → 2.1.22
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/dist/index.js +39 -124
- package/dist/index.js.map +1 -1
- package/dist/version-check.d.ts.map +1 -1
- package/dist/version-check.js +71 -0
- package/dist/version-check.js.map +1 -1
- package/mk3-tui/Cargo.lock +1105 -0
- package/mk3-tui/Cargo.toml +16 -0
- package/mk3-tui/bin/mk3-tui.js +63 -0
- package/mk3-tui/src/app/render_scheduler.rs +103 -0
- package/mk3-tui/src/app.rs +435 -0
- package/mk3-tui/src/io/mod.rs +66 -0
- package/mk3-tui/src/io/protocol.rs +15 -0
- package/mk3-tui/src/io/stdio.rs +32 -0
- package/mk3-tui/src/io/ws.rs +32 -0
- package/mk3-tui/src/main.rs +119 -0
- package/mk3-tui/src/ui/boot.rs +150 -0
- package/mk3-tui/src/ui/layout.rs +705 -0
- package/mk3-tui/src/ui/mod.rs +4 -0
- package/mk3-tui/src/ui/safe_viewport.rs +235 -0
- package/package.json +10 -3
- package/scripts/postinstall-mk3.js +51 -0
|
@@ -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
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
// Try to find the binary in different locations
|
|
12
|
+
const possiblePaths = [
|
|
13
|
+
// From npm package (installed)
|
|
14
|
+
join(__dirname, '..', 'target', 'release', 'mk3-tui'),
|
|
15
|
+
join(__dirname, '..', 'target', 'release', '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'),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
let binaryPath = null;
|
|
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;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!binaryPath) {
|
|
44
|
+
console.error('Error: mk3-tui binary not found.');
|
|
45
|
+
console.error('Please run: cd apps/mk3-tui && cargo build --release');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Spawn the Rust binary with all arguments
|
|
50
|
+
const child = spawn(binaryPath, process.argv.slice(2), {
|
|
51
|
+
stdio: 'inherit',
|
|
52
|
+
cwd: process.cwd(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
child.on('error', (err) => {
|
|
56
|
+
console.error('Failed to start mk3-tui:', err);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.on('exit', (code) => {
|
|
61
|
+
process.exit(code || 0);
|
|
62
|
+
});
|
|
63
|
+
|
|
@@ -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
|
+
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
mod protocol;
|
|
2
|
+
mod stdio;
|
|
3
|
+
mod ws;
|
|
4
|
+
|
|
5
|
+
use crate::app::{App, Message};
|
|
6
|
+
use anyhow::Result;
|
|
7
|
+
use serde_json;
|
|
8
|
+
use std::io::Write;
|
|
9
|
+
use std::sync::mpsc;
|
|
10
|
+
use std::thread;
|
|
11
|
+
|
|
12
|
+
pub struct IoHandler {
|
|
13
|
+
receiver: mpsc::Receiver<Message>,
|
|
14
|
+
#[allow(dead_code)]
|
|
15
|
+
sender: mpsc::Sender<String>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl IoHandler {
|
|
19
|
+
pub fn new() -> Result<Self> {
|
|
20
|
+
let (_tx, rx) = mpsc::channel();
|
|
21
|
+
let (cmd_tx, _cmd_rx) = mpsc::channel();
|
|
22
|
+
|
|
23
|
+
// DO NOT spawn any IO threads that read from stdin!
|
|
24
|
+
// The TUI needs exclusive access to stdin for keyboard input.
|
|
25
|
+
// Future: use WebSocket or named pipe for backend communication.
|
|
26
|
+
|
|
27
|
+
// Command sender thread (writes to stdout, but doesn't block stdin)
|
|
28
|
+
thread::spawn(move || {
|
|
29
|
+
loop {
|
|
30
|
+
match _cmd_rx.recv() {
|
|
31
|
+
Ok(cmd) => {
|
|
32
|
+
let cmd_json = serde_json::json!({
|
|
33
|
+
"type": "command",
|
|
34
|
+
"text": cmd
|
|
35
|
+
});
|
|
36
|
+
if let Ok(json_str) = serde_json::to_string(&cmd_json) {
|
|
37
|
+
println!("{}", json_str);
|
|
38
|
+
let _ = std::io::stdout().flush();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
Err(_) => break,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
Ok(Self {
|
|
47
|
+
receiver: rx,
|
|
48
|
+
sender: cmd_tx,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub fn update(&mut self, app: &mut App) -> Result<()> {
|
|
53
|
+
// Non-blocking receive
|
|
54
|
+
while let Ok(message) = self.receiver.try_recv() {
|
|
55
|
+
app.update(message);
|
|
56
|
+
}
|
|
57
|
+
Ok(())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#[allow(dead_code)]
|
|
61
|
+
pub fn send_command(&mut self, cmd: String) -> Result<()> {
|
|
62
|
+
self.sender.send(cmd)?;
|
|
63
|
+
Ok(())
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
use crate::app::Message;
|
|
2
|
+
use anyhow::Result;
|
|
3
|
+
use std::sync::mpsc;
|
|
4
|
+
|
|
5
|
+
#[allow(dead_code)]
|
|
6
|
+
pub trait Protocol: Send {
|
|
7
|
+
fn run(&mut self) -> Result<()>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[allow(dead_code)]
|
|
11
|
+
pub struct ProtocolContext {
|
|
12
|
+
pub message_tx: mpsc::Sender<Message>,
|
|
13
|
+
pub command_rx: mpsc::Receiver<String>,
|
|
14
|
+
}
|
|
15
|
+
|