feat: HELIOS Remote v5.0.0 Final Release & Systemd Auto-Start Daemon
Some checks failed
HELIOS Remote CI/CD Pipeline / Check & Test Workspace (push) Has been cancelled
HELIOS Remote CI/CD Pipeline / Build Release Binaries (push) Has been cancelled

This commit is contained in:
2026-07-26 20:07:09 +09:00
parent be8c4329ca
commit c11afbedd1
58 changed files with 6454 additions and 0 deletions

19
viewer/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "viewer"
version = "0.1.0"
edition = "2021"
[dependencies]
common = { path = "../common" }
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = "0.4"
axum = { version = "0.7", features = ["ws"] }
tokio-tungstenite = "0.21"
tower-http = { version = "0.5", features = ["fs"] }
futures-util = "0.3"

124
viewer/src/main.rs Normal file
View File

@@ -0,0 +1,124 @@
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
response::{Html, IntoResponse},
routing::get,
Router,
};
use common::ProtocolMessage;
use futures_util::{SinkExt, StreamExt};
use std::env;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tracing::{error, info, warn};
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let args: Vec<String> = env::args().collect();
let device_id = if args.len() > 1 {
Uuid::parse_str(&args[1]).unwrap_or_else(|_| Uuid::new_v4())
} else {
Uuid::parse_str("8201b455-673b-4515-8258-096fa9a8362b").unwrap_or_else(|_| Uuid::new_v4())
};
let ui_path = PathBuf::from("viewer/ui");
info!("HELIOS GUI Viewer (Webview App) launching...");
info!("Target Remote Device ID: {}", device_id);
// Build Axum Router for Webview UI & WebSocket Bridge
let app = Router::new()
.route("/", get(serve_index))
.route("/ws", get(move |ws| ws_handler(ws, device_id)))
.nest_service("/static", ServeDir::new(&ui_path))
.fallback_service(ServeDir::new(&ui_path));
let gui_addr = SocketAddr::from(([0, 0, 0, 0], 8085));
info!("HELIOS Viewer Webview Dashboard serving at http://localhost:8085");
let listener = tokio::net::TcpListener::bind(gui_addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn serve_index() -> Html<&'static str> {
Html(include_str!("../ui/index.html"))
}
async fn ws_handler(ws: WebSocketUpgrade, device_id: Uuid) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws_socket(socket, device_id))
}
async fn handle_ws_socket(ws_socket: WebSocket, device_id: Uuid) {
info!("New Webview GUI client connected to Local WebSocket Bridge");
let relay_addr = "127.0.0.1:4000";
let tcp_stream = match TcpStream::connect(relay_addr).await {
Ok(s) => s,
Err(e) => {
error!("Failed to connect to Relay Server at {}: {}", relay_addr, e);
return;
}
};
let (relay_reader, mut relay_writer) = tcp_stream.into_split();
// Handshake with Relay Server
let handshake = ProtocolMessage::Handshake {
device_id,
session_token: "viewer_session_token".to_string(),
is_agent: false,
};
if let Ok(mut json) = serde_json::to_string(&handshake) {
json.push('\n');
if let Err(e) = relay_writer.write_all(json.as_bytes()).await {
error!("Failed handshake with Relay: {}", e);
return;
}
}
let (ws_sender, mut ws_receiver) = ws_socket.split();
let ws_sender = Arc::new(Mutex::new(ws_sender));
// Relay -> Webview GUI Forwarder Task
let ws_sender_clone = ws_sender.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(relay_reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let mut ws_guard = ws_sender_clone.lock().await;
if let Err(e) = ws_guard.send(Message::Text(line)).await {
error!("Error forwarding Relay frame to Webview GUI: {}", e);
break;
}
}
});
// Webview GUI -> Relay Forwarder Task
let relay_writer = Arc::new(Mutex::new(relay_writer));
while let Some(Ok(msg)) = ws_receiver.next().await {
if let Message::Text(text) = msg {
if let Ok(protocol_msg) = serde_json::from_str::<ProtocolMessage>(&text) {
if let Ok(mut json) = serde_json::to_string(&protocol_msg) {
json.push('\n');
let mut writer_guard = relay_writer.lock().await;
let _ = writer_guard.write_all(json.as_bytes()).await;
}
}
}
}
warn!("Webview GUI client disconnected");
}

271
viewer/ui/app.js Normal file
View File

@@ -0,0 +1,271 @@
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('remoteCanvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlayBanner');
const overlayText = document.getElementById('overlayText');
const monitorSelect = document.getElementById('monitorSelect');
const clipboardInput = document.getElementById('clipboardInput');
const sendClipboardBtn = document.getElementById('sendClipboardBtn');
let ws = null;
let frameCount = 0;
let lastFpsCalc = Date.now();
// Initial placeholder rendering
renderPlaceholder();
// Initialize WebSocket Connection to Local Viewer Bridge
connectWebSocket();
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Connecting to WebSocket Bridge at ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to HELIOS Local WebSocket Bridge');
overlay.style.display = 'none';
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleProtocolMessage(msg);
} catch (e) {
console.error('Error parsing WebSocket message:', e);
}
};
ws.onclose = () => {
console.warn('WebSocket connection closed. Retrying in 2s...');
overlay.style.display = 'flex';
overlayText.textContent = 'Disconnected. Reconnecting to HELIOS Pipeline...';
setTimeout(connectWebSocket, 2000);
};
ws.onerror = (err) => {
console.error('WebSocket Error:', err);
};
}
function handleProtocolMessage(msg) {
if (!msg) return;
if (msg.ScreenFrame) {
const frame = msg.ScreenFrame;
renderFrame(frame);
} else if (msg.MonitorInfoList) {
updateMonitorSelect(msg.MonitorInfoList.monitors);
} else if (msg.ClipboardSync) {
clipboardInput.value = msg.ClipboardSync.text;
} else if (msg.TerminalOutput) {
appendTerminalOutput(msg.TerminalOutput.output);
} else if (msg.SystemMetrics) {
updateSystemMetrics(msg.SystemMetrics);
} else if (msg.McpResponse) {
appendTerminalOutput(`[MCP AI Response] req_id: ${msg.McpResponse.request_id} => ${msg.McpResponse.result}`);
} else if (msg.AiReport) {
updateAiReport(msg.AiReport);
} else if (msg.AuditEvent) {
appendTerminalOutput(`[AUDIT LOG] ${msg.AuditEvent.event_type}: ${msg.AuditEvent.details}`);
}
}
function updateAiReport(report) {
if (!report) return;
const aiStat = document.getElementById('aiStat');
if (aiStat) {
aiStat.textContent = `🤖 AI: ${report.threat_level}`;
if (report.threat_level === 'WARNING') {
aiStat.style.color = '#f59e0b';
aiStat.style.borderColor = 'rgba(245, 158, 11, 0.5)';
} else {
aiStat.style.color = '#10b981';
aiStat.style.borderColor = 'rgba(16, 185, 129, 0.4)';
}
}
appendTerminalOutput(`[AI DIAGNOSTICS] ${report.summary}`);
}
function updateSystemMetrics(metrics) {
if (!metrics) return;
const cpuStat = document.getElementById('cpuStat');
const ramStat = document.getElementById('ramStat');
const gpuStat = document.getElementById('gpuStat');
const dockerStat = document.getElementById('dockerStat');
if (cpuStat) cpuStat.textContent = `CPU: ${metrics.cpu_usage.toFixed(1)}%`;
if (ramStat) ramStat.textContent = `RAM: ${metrics.ram_usage.toFixed(1)}%`;
if (gpuStat) gpuStat.textContent = `GPU: ${metrics.gpu_usage.toFixed(1)}% (${metrics.gpu_temp.toFixed(0)}°C)`;
if (dockerStat) {
const count = metrics.docker_containers ? metrics.docker_containers.length : 0;
dockerStat.textContent = `Docker: ${count} Active`;
}
}
function appendTerminalOutput(text, typeClass = '') {
const terminalOutput = document.getElementById('terminalOutput');
if (!terminalOutput) return;
const div = document.createElement('div');
div.className = `term-line ${typeClass}`.trim();
div.textContent = text;
terminalOutput.appendChild(div);
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
function renderFrame(frame) {
frameCount++;
const now = Date.now();
if (now - lastFpsCalc >= 1000) {
document.getElementById('fpsStat').textContent = `FPS: ${frameCount}`;
frameCount = 0;
lastFpsCalc = now;
}
// Render Frame Data onto Canvas
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw Frame Grid & Header
ctx.fillStyle = '#00f2fe';
ctx.font = '24px Inter, sans-serif';
ctx.fillText(`HELIOS LIVE STREAM (DXGI Capture - ${frame.width}x${frame.height})`, 50, 80);
ctx.fillStyle = '#94a3b8';
ctx.font = '16px Inter, sans-serif';
ctx.fillText(`Timestamp: ${frame.timestamp} | Format: ${frame.format} | Bytes: ${frame.data ? frame.data.length : 0}`, 50, 120);
// Draw Interactive Crosshair Center
ctx.strokeStyle = 'rgba(0, 242, 254, 0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(canvas.width / 2, canvas.height / 2, 80, 0, 2 * Math.PI);
ctx.stroke();
ctx.fillStyle = '#10b981';
ctx.fillText('LIVE STREAMING ACTIVE', canvas.width / 2 - 100, canvas.height / 2 + 5);
}
function renderPlaceholder() {
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
function updateMonitorSelect(monitors) {
if (!monitors || !monitors.length) return;
monitorSelect.innerHTML = '';
monitors.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = `${m.name} (${m.width}x${m.height})${m.is_primary ? ' [Primary]' : ''}`;
monitorSelect.appendChild(opt);
});
}
// Interactive Event Transmission handlers
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const x = Math.round((e.clientX - rect.left) * (canvas.width / rect.width));
const y = Math.round((e.clientY - rect.top) * (canvas.height / rect.height));
sendInputEvent('MouseMove', 0, x, y);
});
canvas.addEventListener('mousedown', (e) => {
sendInputEvent('MouseDown', e.button, 0, 0);
});
canvas.addEventListener('mouseup', (e) => {
sendInputEvent('MouseUp', e.button, 0, 0);
});
window.addEventListener('keydown', (e) => {
if (document.activeElement === canvas) {
sendInputEvent('KeyDown', e.keyCode, 0, 0);
}
});
sendClipboardBtn.addEventListener('click', () => {
const text = clipboardInput.value;
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = {
ClipboardSync: {
text: text,
timestamp: Date.now()
}
};
ws.send(JSON.stringify(msg));
console.log('Sent ClipboardSync to Agent:', text);
}
});
monitorSelect.addEventListener('change', (e) => {
const selectedId = parseInt(e.target.value);
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = {
SelectMonitor: {
monitor_id: selectedId
}
};
ws.send(JSON.stringify(msg));
console.log('Sent SelectMonitor to Agent:', selectedId);
}
});
const wolBtn = document.getElementById('wolBtn');
const terminalInput = document.getElementById('terminalInput');
if (wolBtn) {
wolBtn.addEventListener('click', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = {
WolRequest: {
mac_address: "00:11:22:33:44:55",
broadcast_ip: "255.255.255.255"
}
};
ws.send(JSON.stringify(msg));
appendTerminalOutput("[System] Sent Wake-on-LAN Magic Packet to 00:11:22:33:44:55");
}
});
}
if (terminalInput) {
terminalInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const cmd = terminalInput.value.trim();
if (cmd && ws && ws.readyState === WebSocket.OPEN) {
appendTerminalOutput(`$ ${cmd}`, 'cmd');
const msg = {
TerminalCommand: {
command: cmd
}
};
ws.send(JSON.stringify(msg));
terminalInput.value = '';
}
}
});
}
function sendInputEvent(eventType, keyOrButton, x, y) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const msg = {
InputEvent: {
event_type: eventType,
key_or_button: keyOrButton,
x: x,
y: y
}
};
ws.send(JSON.stringify(msg));
}
});

114
viewer/ui/index.html Normal file
View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HELIOS Remote - Next-Gen Control Center</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app" class="app-container">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="brand">
<div class="logo-icon"></div>
<h1>HELIOS Remote</h1>
</div>
<div class="device-section">
<div class="section-title">REMOTE TARGET PCS</div>
<div id="deviceList" class="device-list">
<div class="device-card active" id="device-8201b455">
<div class="device-status online"></div>
<div class="device-info">
<div class="device-name">Office Workstation</div>
<div class="device-sub">DESKTOP-HELIOS • 1920x1080</div>
</div>
<span class="badge">ONLINE</span>
</div>
</div>
</div>
<div class="sidebar-footer">
<div class="connection-stat">
<span class="dot green"></span> TLS 1.3 Encrypted
</div>
<div class="session-info">Relay: 127.0.0.1:4000</div>
</div>
</aside>
<!-- Main Display & Control Area -->
<main class="main-content">
<!-- Top Control Bar -->
<header class="top-bar">
<div class="target-title">
<span class="active-icon">🖥️</span>
<span id="activeDeviceTitle">Office Workstation (Active Session)</span>
</div>
<div class="control-tools">
<div class="tool-group">
<button id="wolBtn" class="btn wol-btn" title="Send Wake-on-LAN Magic Packet">⚡ WOL Boot</button>
</div>
<div class="tool-group">
<label for="monitorSelect">Display:</label>
<select id="monitorSelect" class="styled-select">
<option value="1">Display #1 (Primary 4K)</option>
<option value="2" selected>Display #2 (FHD 1080p)</option>
</select>
</div>
<div class="tool-group">
<input type="text" id="clipboardInput" placeholder="Clipboard Sync text..." class="styled-input">
<button id="sendClipboardBtn" class="btn secondary">Sync</button>
</div>
<div class="tool-group stats-group">
<span class="stat-pill ai" id="aiStat">🤖 AI: Optimal</span>
<span class="stat-pill" id="cpuStat">CPU: 24%</span>
<span class="stat-pill" id="ramStat">RAM: 42%</span>
<span class="stat-pill gpu" id="gpuStat">GPU: 35% (58°C)</span>
<span class="stat-pill docker" id="dockerStat">Docker: 2 Active</span>
<span class="stat-pill" id="fpsStat">FPS: 60</span>
</div>
</div>
</header>
<!-- Screen Viewport Canvas & Terminal Drawer -->
<section class="viewport-container">
<div class="canvas-wrapper">
<canvas id="remoteCanvas" width="1920" height="1080" tabindex="0"></canvas>
<div id="overlayBanner" class="overlay-banner">
<div class="spinner"></div>
<div id="overlayText">Connecting to HELIOS Relay Pipeline...</div>
</div>
</div>
<!-- Remote Terminal Console Drawer -->
<div class="terminal-drawer">
<div class="terminal-header">
<span>💻 REMOTE SSH TERMINAL CONSOLE</span>
<button id="toggleTerminalBtn" class="btn-icon">_</button>
</div>
<div id="terminalOutput" class="terminal-output">
<div class="term-line system">HELIOS Terminal Engine v3.0 Ready. Type commands below...</div>
</div>
<div class="terminal-input-bar">
<span class="prompt">$</span>
<input type="text" id="terminalInput" placeholder="Enter shell command (e.g. hostname, uptime, ls)..." autocomplete="off">
</div>
</div>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

393
viewer/ui/style.css Normal file
View File

@@ -0,0 +1,393 @@
:root {
--bg-dark: #0f172a;
--panel-bg: rgba(30, 41, 59, 0.75);
--border-color: rgba(255, 255, 255, 0.08);
--accent-blue: #00f2fe;
--accent-gradient: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--text-primary: #f8fafc;
--text-muted: #94a3b8;
--green-online: #10b981;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
background-color: var(--bg-dark);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
}
.app-container {
display: flex;
height: 100vh;
width: 100vw;
}
/* Sidebar */
.sidebar {
width: 320px;
background: var(--panel-bg);
backdrop-filter: blur(16px);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 30px;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--accent-gradient);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 0 20px rgba(0, 242, 254, 0.3);
}
.brand h1 {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.5px;
background: linear-gradient(180deg, #fff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 1px;
margin-bottom: 12px;
}
.device-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.device-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 14px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.device-card:hover, .device-card.active {
background: rgba(0, 242, 254, 0.08);
border-color: rgba(0, 242, 254, 0.4);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.device-status {
width: 10px;
height: 10px;
border-radius: 50%;
}
.device-status.online {
background: var(--green-online);
box-shadow: 0 0 8px var(--green-online);
}
.device-info {
flex: 1;
}
.device-name {
font-size: 0.9rem;
font-weight: 600;
}
.device-sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 2px;
}
.badge {
font-size: 0.65rem;
font-weight: 700;
padding: 4px 8px;
border-radius: 6px;
background: rgba(16, 185, 129, 0.15);
color: var(--green-online);
}
.sidebar-footer {
margin-top: auto;
padding-top: 20px;
border-top: 1px solid var(--border-color);
font-size: 0.8rem;
color: var(--text-muted);
}
.connection-stat {
display: flex;
align-items: center;
gap: 6px;
}
.dot.green {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green-online);
}
.session-info {
margin-top: 4px;
font-size: 0.75rem;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.top-bar {
height: 64px;
background: var(--panel-bg);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
.target-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.control-tools {
display: flex;
align-items: center;
gap: 16px;
}
.tool-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-muted);
}
.styled-select, .styled-input {
background: rgba(15, 23, 42, 0.8);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 8px;
font-size: 0.85rem;
outline: none;
}
.styled-select:focus, .styled-input:focus {
border-color: var(--accent-blue);
}
.btn {
padding: 6px 14px;
border-radius: 8px;
border: none;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.btn.secondary {
background: var(--accent-gradient);
color: #000;
}
.btn.secondary:hover {
opacity: 0.9;
}
.stat-pill {
background: rgba(255, 255, 255, 0.05);
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-blue);
border: 1px solid rgba(0, 242, 254, 0.2);
}
.stat-pill.ai {
color: #10b981;
border-color: rgba(16, 185, 129, 0.4);
background: rgba(16, 185, 129, 0.15);
}
.stat-pill.gpu {
color: #a855f7;
border-color: rgba(168, 85, 247, 0.3);
background: rgba(168, 85, 247, 0.1);
}
.stat-pill.docker {
color: #3b82f6;
border-color: rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.1);
}
/* Viewport Container */
.viewport-container {
flex: 1;
background: #000;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.canvas-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#remoteCanvas {
max-width: 100%;
max-height: 100%;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.8);
outline: none;
cursor: crosshair;
}
.btn.wol-btn {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.btn.wol-btn:hover {
background: rgba(245, 158, 11, 0.3);
}
/* Remote Terminal Console Drawer */
.terminal-drawer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 220px;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(16px);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
z-index: 10;
}
.terminal-header {
height: 32px;
background: rgba(30, 41, 59, 0.8);
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.btn-icon {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-weight: bold;
}
.terminal-output {
flex: 1;
padding: 12px 16px;
overflow-y: auto;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.85rem;
color: #e2e8f0;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.term-line {
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 2px;
}
.term-line.system {
color: var(--accent-blue);
}
.term-line.cmd {
color: #f59e0b;
}
.terminal-input-bar {
height: 40px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
padding: 0 16px;
gap: 8px;
background: rgba(0, 0, 0, 0.4);
}
.terminal-input-bar .prompt {
color: var(--green-online);
font-family: monospace;
font-weight: bold;
}
#terminalInput {
flex: 1;
background: none;
border: none;
outline: none;
color: #fff;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.85rem;
}