From 06cfb3d9db1f003fd94216e449f89694145fd5bb Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Sat, 21 Mar 2026 21:46:45 +0700 Subject: [PATCH] feat(tui): add terminal dashboard for Mission Control Zero-dependency TUI using raw ANSI escape codes. Shows: - System health status with connection indicator - Agents panel (sorted by status, with last heartbeat) - Tasks panel (recent tasks with status and assignee) - Token costs summary (24h) Keyboard controls: - r: refresh now - a: focus agents panel - t: focus tasks panel - w: wake first sleeping agent - q/Esc: quit Auto-refreshes every 5s (configurable with --refresh flag). Uses same profile/auth system as CLI and MCP server. Run with: pnpm mc:tui --- package.json | 1 + scripts/mc-tui.cjs | 416 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100755 scripts/mc-tui.cjs diff --git a/package.json b/package.json index 30c510b..423e5c7 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "api:parity:json": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore --json", "mc": "node scripts/mc-cli.cjs", "mc:mcp": "node scripts/mc-mcp-server.cjs", + "mc:tui": "node scripts/mc-tui.cjs", "dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}", "build": "pnpm run verify:node && next build", "start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}", diff --git a/scripts/mc-tui.cjs b/scripts/mc-tui.cjs new file mode 100755 index 0000000..f2f5f58 --- /dev/null +++ b/scripts/mc-tui.cjs @@ -0,0 +1,416 @@ +#!/usr/bin/env node +/* + Mission Control TUI (v1) + - Zero dependencies (ANSI escape codes) + - Dashboard with agents/tasks/sessions panels + - Keyboard-driven refresh and navigation + - Trigger operations: wake agent, queue poll + - Graceful degradation when endpoints unavailable + + Usage: + node scripts/mc-tui.cjs [--url ] [--api-key ] [--profile ] [--refresh ] +*/ + +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const readline = require('node:readline'); + +// --------------------------------------------------------------------------- +// Config (shared with mc-cli.cjs) +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const flags = {}; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (!t.startsWith('--')) continue; + const key = t.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { flags[key] = true; continue; } + flags[key] = next; + i++; + } + return flags; +} + +function loadProfile(name) { + const p = path.join(os.homedir(), '.mission-control', 'profiles', `${name}.json`); + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf8')); + return { + url: parsed.url || process.env.MC_URL || 'http://127.0.0.1:3000', + apiKey: parsed.apiKey || process.env.MC_API_KEY || '', + cookie: parsed.cookie || process.env.MC_COOKIE || '', + }; + } catch { + return { + url: process.env.MC_URL || 'http://127.0.0.1:3000', + apiKey: process.env.MC_API_KEY || '', + cookie: process.env.MC_COOKIE || '', + }; + } +} + +// --------------------------------------------------------------------------- +// HTTP client +// --------------------------------------------------------------------------- + +async function api(baseUrl, apiKey, cookie, method, route) { + const headers = { Accept: 'application/json' }; + if (apiKey) headers['x-api-key'] = apiKey; + if (cookie) headers['Cookie'] = cookie; + const url = `${baseUrl.replace(/\/+$/, '')}${route}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + try { + const res = await fetch(url, { method, headers, signal: controller.signal }); + clearTimeout(timer); + if (!res.ok) return { _error: `HTTP ${res.status}` }; + return await res.json(); + } catch (err) { + clearTimeout(timer); + return { _error: err?.name === 'AbortError' ? 'timeout' : (err?.message || 'network error') }; + } +} + +// --------------------------------------------------------------------------- +// ANSI helpers +// --------------------------------------------------------------------------- + +const ESC = '\x1b['; +const ansi = { + clear: () => process.stdout.write(`${ESC}2J${ESC}H`), + moveTo: (row, col) => process.stdout.write(`${ESC}${row};${col}H`), + bold: (s) => `${ESC}1m${s}${ESC}0m`, + dim: (s) => `${ESC}2m${s}${ESC}0m`, + green: (s) => `${ESC}32m${s}${ESC}0m`, + yellow: (s) => `${ESC}33m${s}${ESC}0m`, + red: (s) => `${ESC}31m${s}${ESC}0m`, + cyan: (s) => `${ESC}36m${s}${ESC}0m`, + magenta: (s) => `${ESC}35m${s}${ESC}0m`, + bgBlue: (s) => `${ESC}44m${ESC}97m${s}${ESC}0m`, + hideCursor: () => process.stdout.write(`${ESC}?25l`), + showCursor: () => process.stdout.write(`${ESC}?25h`), + clearLine: () => process.stdout.write(`${ESC}2K`), +}; + +function getTermSize() { + return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }; +} + +function truncate(s, maxLen) { + if (!s) return ''; + return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s; +} + +function pad(s, len) { + const str = String(s || ''); + return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length); +} + +function statusColor(status) { + const s = String(status || '').toLowerCase(); + if (s === 'online' || s === 'active' || s === 'done' || s === 'healthy' || s === 'completed') return ansi.green(status); + if (s === 'idle' || s === 'sleeping' || s === 'in_progress' || s === 'pending' || s === 'warning') return ansi.yellow(status); + if (s === 'offline' || s === 'error' || s === 'failed' || s === 'critical' || s === 'unhealthy') return ansi.red(status); + return status; +} + +// --------------------------------------------------------------------------- +// Data fetching +// --------------------------------------------------------------------------- + +async function fetchDashboardData(baseUrl, apiKey, cookie) { + const [health, agents, tasks, tokens] = await Promise.all([ + api(baseUrl, apiKey, cookie, 'GET', '/api/status?action=health'), + api(baseUrl, apiKey, cookie, 'GET', '/api/agents'), + api(baseUrl, apiKey, cookie, 'GET', '/api/tasks?limit=15'), + api(baseUrl, apiKey, cookie, 'GET', '/api/tokens?action=stats&timeframe=day'), + ]); + return { health, agents, tasks, tokens }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +function renderHeader(cols, baseUrl, healthData, refreshMs) { + const title = ' MISSION CONTROL '; + const bar = ansi.bgBlue(pad(title, cols)); + process.stdout.write(bar + '\n'); + + const status = healthData?._error + ? ansi.red('UNREACHABLE') + : statusColor(healthData?.status || 'unknown'); + const url = ansi.dim(baseUrl); + const refresh = ansi.dim(`refresh: ${refreshMs / 1000}s`); + const time = ansi.dim(new Date().toLocaleTimeString()); + process.stdout.write(` ${status} ${url} ${refresh} ${time}\n`); +} + +function renderAgentsPanel(agentsData, cols, maxRows) { + process.stdout.write('\n' + ansi.bold(ansi.cyan(' AGENTS')) + '\n'); + + if (agentsData?._error) { + process.stdout.write(ansi.dim(` (unavailable: ${agentsData._error})\n`)); + return; + } + + const agents = agentsData?.agents || agentsData || []; + if (!Array.isArray(agents) || agents.length === 0) { + process.stdout.write(ansi.dim(' (no agents)\n')); + return; + } + + const nameW = 18; + const roleW = 14; + const statusW = 12; + const header = ansi.dim(` ${pad('Name', nameW)} ${pad('Role', roleW)} ${pad('Status', statusW)} Last Seen`); + process.stdout.write(header + '\n'); + + const sorted = [...agents].sort((a, b) => { + const order = { online: 0, active: 0, idle: 1, sleeping: 2, offline: 3 }; + return (order[a.status] ?? 4) - (order[b.status] ?? 4); + }); + + for (let i = 0; i < Math.min(sorted.length, maxRows); i++) { + const a = sorted[i]; + const name = pad(truncate(a.name, nameW), nameW); + const role = pad(truncate(a.role, roleW), roleW); + const status = pad(statusColor(a.status || 'unknown'), statusW + 9); // +9 for ANSI codes + const lastSeen = a.last_heartbeat + ? ansi.dim(timeSince(a.last_heartbeat)) + : ansi.dim('never'); + process.stdout.write(` ${name} ${role} ${status} ${lastSeen}\n`); + } + + if (sorted.length > maxRows) { + process.stdout.write(ansi.dim(` ... and ${sorted.length - maxRows} more\n`)); + } +} + +function renderTasksPanel(tasksData, cols, maxRows) { + process.stdout.write('\n' + ansi.bold(ansi.magenta(' TASKS')) + '\n'); + + if (tasksData?._error) { + process.stdout.write(ansi.dim(` (unavailable: ${tasksData._error})\n`)); + return; + } + + const tasks = tasksData?.tasks || tasksData || []; + if (!Array.isArray(tasks) || tasks.length === 0) { + process.stdout.write(ansi.dim(' (no tasks)\n')); + return; + } + + const idW = 5; + const titleW = Math.min(35, cols - 40); + const statusW = 14; + const assignW = 14; + const header = ansi.dim(` ${pad('ID', idW)} ${pad('Title', titleW)} ${pad('Status', statusW)} ${pad('Assigned', assignW)}`); + process.stdout.write(header + '\n'); + + for (let i = 0; i < Math.min(tasks.length, maxRows); i++) { + const t = tasks[i]; + const id = pad(String(t.id || ''), idW); + const title = pad(truncate(t.title, titleW), titleW); + const status = pad(statusColor(t.status || ''), statusW + 9); + const assigned = pad(truncate(t.assigned_to || '-', assignW), assignW); + process.stdout.write(` ${id} ${title} ${status} ${assigned}\n`); + } + + const total = tasksData?.total || tasks.length; + if (total > maxRows) { + process.stdout.write(ansi.dim(` ... ${total} total tasks\n`)); + } +} + +function renderTokensPanel(tokensData) { + process.stdout.write('\n' + ansi.bold(ansi.yellow(' COSTS (24h)')) + '\n'); + + if (tokensData?._error) { + process.stdout.write(ansi.dim(` (unavailable: ${tokensData._error})\n`)); + return; + } + + const summary = tokensData?.summary || {}; + const cost = summary.totalCost != null ? `$${summary.totalCost.toFixed(4)}` : '-'; + const tokens = summary.totalTokens != null ? formatNumber(summary.totalTokens) : '-'; + const requests = summary.requestCount != null ? formatNumber(summary.requestCount) : '-'; + + process.stdout.write(` Cost: ${ansi.bold(cost)} Tokens: ${tokens} Requests: ${requests}\n`); +} + +function renderFooter(cols, selectedPanel, actionMessage) { + process.stdout.write('\n'); + if (actionMessage) { + process.stdout.write(ansi.green(` ${actionMessage}\n`)); + } + const keys = ansi.dim(' [r]efresh [a]gents [t]asks [w]ake agent [q]uit'); + process.stdout.write(keys + '\n'); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function timeSince(ts) { + const now = Date.now(); + const then = typeof ts === 'number' ? (ts < 1e12 ? ts * 1000 : ts) : new Date(ts).getTime(); + const diff = Math.max(0, now - then); + if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; +} + +function formatNumber(n) { + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + return String(n); +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +async function wakeAgent(baseUrl, apiKey, cookie, agentsData) { + const agents = agentsData?.agents || agentsData || []; + const sleeping = agents.filter(a => a.status === 'sleeping' || a.status === 'idle' || a.status === 'offline'); + + if (sleeping.length === 0) return 'No sleeping/idle agents to wake'; + + // Wake the first sleeping agent + const target = sleeping[0]; + const result = await api(baseUrl, apiKey, cookie, 'POST', `/api/agents/${target.id}/wake`); + if (result?._error) return `Wake failed: ${result._error}`; + return `Woke agent: ${target.name}`; +} + +// --------------------------------------------------------------------------- +// Main loop +// --------------------------------------------------------------------------- + +async function main() { + const flags = parseArgs(process.argv.slice(2)); + + if (flags.help) { + console.log(`Mission Control TUI + +Usage: + node scripts/mc-tui.cjs [--url ] [--api-key ] [--profile ] [--refresh ] + +Keys: + r Refresh now + a Focus agents panel + t Focus tasks panel + w Wake first sleeping agent + q/Esc Quit +`); + process.exit(0); + } + + const profile = loadProfile(String(flags.profile || 'default')); + const baseUrl = flags.url ? String(flags.url) : profile.url; + const apiKey = flags['api-key'] ? String(flags['api-key']) : profile.apiKey; + const cookie = profile.cookie; + const refreshMs = Number(flags.refresh || 5000); + + // Raw mode for keyboard input + if (process.stdin.isTTY) { + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + } + + ansi.hideCursor(); + + let running = true; + let data = { health: {}, agents: {}, tasks: {}, tokens: {} }; + let actionMessage = ''; + let selectedPanel = 'agents'; + + // Graceful shutdown + function cleanup() { + running = false; + ansi.showCursor(); + ansi.clear(); + process.exit(0); + } + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Keyboard handler + process.stdin.on('keypress', async (str, key) => { + if (!key) return; + if (key.name === 'q' || (key.name === 'escape')) { + cleanup(); + return; + } + if (key.name === 'c' && key.ctrl) { + cleanup(); + return; + } + if (key.name === 'r') { + actionMessage = 'Refreshing...'; + render(); + data = await fetchDashboardData(baseUrl, apiKey, cookie); + actionMessage = 'Refreshed'; + render(); + setTimeout(() => { actionMessage = ''; render(); }, 2000); + } + if (key.name === 'a') { selectedPanel = 'agents'; render(); } + if (key.name === 't') { selectedPanel = 'tasks'; render(); } + if (key.name === 'w') { + actionMessage = 'Waking agent...'; + render(); + actionMessage = await wakeAgent(baseUrl, apiKey, cookie, data.agents); + render(); + // Refresh after wake + data = await fetchDashboardData(baseUrl, apiKey, cookie); + render(); + setTimeout(() => { actionMessage = ''; render(); }, 3000); + } + }); + + function render() { + const { cols, rows } = getTermSize(); + ansi.clear(); + + renderHeader(cols, baseUrl, data.health, refreshMs); + + // Calculate available rows for panels + const headerRows = 3; + const footerRows = 3; + const panelHeaderRows = 6; // section headers + token panel + const available = Math.max(4, rows - headerRows - footerRows - panelHeaderRows); + const agentRows = Math.floor(available * 0.45); + const taskRows = Math.floor(available * 0.55); + + renderAgentsPanel(data.agents, cols, agentRows); + renderTasksPanel(data.tasks, cols, taskRows); + renderTokensPanel(data.tokens); + renderFooter(cols, selectedPanel, actionMessage); + } + + // Initial fetch and render + actionMessage = 'Loading...'; + render(); + data = await fetchDashboardData(baseUrl, apiKey, cookie); + actionMessage = ''; + render(); + + // Auto-refresh loop + while (running) { + await new Promise(resolve => setTimeout(resolve, refreshMs)); + if (!running) break; + data = await fetchDashboardData(baseUrl, apiKey, cookie); + if (actionMessage === '') render(); // Don't overwrite action messages + } +} + +main().catch(err => { + ansi.showCursor(); + console.error('TUI error:', err.message); + process.exit(1); +});