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
This commit is contained in:
parent
a9d9005bac
commit
06cfb3d9db
|
|
@ -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",
|
"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": "node scripts/mc-cli.cjs",
|
||||||
"mc:mcp": "node scripts/mc-mcp-server.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}",
|
"dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
|
||||||
"build": "pnpm run verify:node && next build",
|
"build": "pnpm run verify:node && next build",
|
||||||
"start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}",
|
"start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}",
|
||||||
|
|
|
||||||
|
|
@ -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 <base>] [--api-key <key>] [--profile <name>] [--refresh <ms>]
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 <base>] [--api-key <key>] [--profile <name>] [--refresh <ms>]
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue