From 3ada2e53803b30c582b66edd8c56f1bb574db161 Mon Sep 17 00:00:00 2001
From: Nyk <0xnykcd@googlemail.com>
Date: Sat, 21 Mar 2026 22:48:32 +0700
Subject: [PATCH] feat(tui): v2 with arrow navigation, agent detail, and chat
viewer
- Arrow keys navigate agent/task lists with highlighted selection
- Tab switches between agents and tasks panels
- Enter on agent opens detail view with sessions list
- Enter on session loads chat transcript
- PgUp/PgDn scrolls chat history
- Esc goes back to dashboard
- Scrolling window keeps cursor visible in long lists
---
scripts/mc-tui.cjs | 688 +++++++++++++++++++++++++++++----------------
1 file changed, 449 insertions(+), 239 deletions(-)
diff --git a/scripts/mc-tui.cjs b/scripts/mc-tui.cjs
index e0159a3..0db3e22 100755
--- a/scripts/mc-tui.cjs
+++ b/scripts/mc-tui.cjs
@@ -1,11 +1,11 @@
#!/usr/bin/env node
/*
- Mission Control TUI (v1)
+ Mission Control TUI (v2)
- 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
+ - Arrow key navigation between agents/tasks
+ - Enter to drill into agent detail with sessions
+ - Esc to go back, q to quit
+ - Auto-refresh dashboard
Usage:
node scripts/mc-tui.cjs [--url ] [--api-key ] [--profile ] [--refresh ]
@@ -17,7 +17,7 @@ const os = require('node:os');
const readline = require('node:readline');
// ---------------------------------------------------------------------------
-// Config (shared with mc-cli.cjs)
+// Config
// ---------------------------------------------------------------------------
function parseArgs(argv) {
@@ -90,6 +90,8 @@ const ansi = {
cyan: (s) => `${ESC}36m${s}${ESC}0m`,
magenta: (s) => `${ESC}35m${s}${ESC}0m`,
bgBlue: (s) => `${ESC}44m${ESC}97m${s}${ESC}0m`,
+ bgCyan: (s) => `${ESC}46m${ESC}30m${s}${ESC}0m`,
+ inverse: (s) => `${ESC}7m${s}${ESC}0m`,
hideCursor: () => process.stdout.write(`${ESC}?25l`),
showCursor: () => process.stdout.write(`${ESC}?25h`),
clearLine: () => process.stdout.write(`${ESC}2K`),
@@ -119,162 +121,6 @@ function statusColor(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');
-
- let status;
- if (healthData?._error) {
- status = ansi.red('UNREACHABLE');
- } else {
- // Show healthy if essential checks pass (DB + Disk), even when
- // gateway is down or dev-mode memory is high
- const checks = healthData?.checks || [];
- const essentialNames = new Set(['Database', 'Disk Space']);
- const essentialChecks = checks.filter(c => essentialNames.has(c.name));
- const essentialOk = essentialChecks.length > 0 && essentialChecks.every(c => c.status === 'healthy');
- const warnings = checks.filter(c => !essentialNames.has(c.name) && c.status !== 'healthy');
- const warningNames = warnings.map(c => c.name.toLowerCase()).join(', ');
-
- if (essentialOk && warnings.length === 0) {
- status = ansi.green('healthy');
- } else if (essentialOk) {
- status = ansi.yellow('operational') + ansi.dim(` (${warningNames})`);
- } else {
- status = 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();
@@ -291,27 +137,288 @@ function formatNumber(n) {
return String(n);
}
+// Strip ANSI codes for length calculation
+function stripAnsi(s) {
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
+}
+
// ---------------------------------------------------------------------------
-// Actions
+// Data fetching
// ---------------------------------------------------------------------------
-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');
+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=30'),
+ api(baseUrl, apiKey, cookie, 'GET', '/api/tokens?action=stats&timeframe=day'),
+ ]);
+ return { health, agents, tasks, tokens };
+}
- if (sleeping.length === 0) return 'No sleeping/idle agents to wake';
+async function fetchAgentSessions(baseUrl, apiKey, cookie, agentName) {
+ const sessions = await api(baseUrl, apiKey, cookie, 'GET', '/api/sessions');
+ if (sessions?._error) return sessions;
+ const all = sessions?.sessions || [];
+ // Match sessions by agent name (sessions use project path as agent key)
+ const matched = all.filter(s => {
+ const key = s.agent || s.key || '';
+ const name = key.split('/').pop() || key;
+ return name === agentName || key.includes(agentName);
+ });
+ return { sessions: matched.length > 0 ? matched : all.slice(0, 10) };
+}
- // 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}`;
+async function fetchTranscript(baseUrl, apiKey, cookie, sessionId, limit) {
+ return api(baseUrl, apiKey, cookie, 'GET',
+ `/api/sessions/transcript?kind=claude-code&id=${encodeURIComponent(sessionId)}&limit=${limit}`);
+}
+
+// ---------------------------------------------------------------------------
+// Views
+// ---------------------------------------------------------------------------
+
+// State
+const state = {
+ view: 'dashboard', // 'dashboard' | 'agent-detail'
+ panel: 'agents', // 'agents' | 'tasks'
+ cursorAgent: 0,
+ cursorTask: 0,
+ scrollOffset: 0,
+ selectedAgent: null,
+ agentSessions: null,
+ agentTranscript: null,
+ transcriptSessionIdx: 0,
+ transcriptScroll: 0,
+ data: { health: {}, agents: {}, tasks: {}, tokens: {} },
+ actionMessage: '',
+};
+
+function getAgentsList() {
+ const raw = state.data.agents?.agents || state.data.agents || [];
+ if (!Array.isArray(raw)) return [];
+ return [...raw].sort((a, b) => {
+ const order = { online: 0, active: 0, idle: 1, sleeping: 2, offline: 3 };
+ return (order[a.status] ?? 4) - (order[b.status] ?? 4);
+ });
+}
+
+function getTasksList() {
+ const raw = state.data.tasks?.tasks || state.data.tasks || [];
+ return Array.isArray(raw) ? raw : [];
+}
+
+// --- Dashboard View ---
+
+function renderDashboard() {
+ const { cols, rows } = getTermSize();
+ ansi.clear();
+
+ // Header
+ const title = ' MISSION CONTROL ';
+ process.stdout.write(ansi.bgBlue(pad(title, cols)) + '\n');
+
+ const healthData = state.data.health;
+ let status;
+ if (healthData?._error) {
+ status = ansi.red('UNREACHABLE');
+ } else {
+ const checks = healthData?.checks || [];
+ const essentialNames = new Set(['Database', 'Disk Space']);
+ const essentialChecks = checks.filter(c => essentialNames.has(c.name));
+ const essentialOk = essentialChecks.length > 0 && essentialChecks.every(c => c.status === 'healthy');
+ const warnings = checks.filter(c => !essentialNames.has(c.name) && c.status !== 'healthy');
+ const warningNames = warnings.map(c => c.name.toLowerCase()).join(', ');
+ if (essentialOk && warnings.length === 0) status = ansi.green('healthy');
+ else if (essentialOk) status = ansi.yellow('operational') + ansi.dim(` (${warningNames})`);
+ else status = statusColor(healthData?.status || 'unknown');
+ }
+ process.stdout.write(` ${status} ${ansi.dim(baseUrl)} ${ansi.dim(new Date().toLocaleTimeString())}\n`);
+
+ // Panel tabs
+ const agentTab = state.panel === 'agents' ? ansi.bgCyan(' AGENTS ') : ansi.dim(' AGENTS ');
+ const taskTab = state.panel === 'tasks' ? ansi.bgCyan(' TASKS ') : ansi.dim(' TASKS ');
+ process.stdout.write(`\n ${agentTab} ${taskTab}\n`);
+
+ const headerRows = 5;
+ const footerRows = 4;
+ const panelRows = Math.max(4, rows - headerRows - footerRows);
+
+ if (state.panel === 'agents') {
+ renderAgentsList(cols, panelRows);
+ } else {
+ renderTasksList(cols, panelRows);
+ }
+
+ // Costs bar
+ const tokensData = state.data.tokens;
+ const summary = tokensData?.summary || {};
+ const cost = summary.totalCost != null ? `$${summary.totalCost.toFixed(4)}` : '-';
+ const tokens = summary.totalTokens != null ? formatNumber(summary.totalTokens) : '-';
+ process.stdout.write(`\n ${ansi.dim('24h:')} ${ansi.bold(cost)} ${ansi.dim('tokens:')} ${tokens}\n`);
+
+ // Footer
+ if (state.actionMessage) process.stdout.write(ansi.green(` ${state.actionMessage}\n`));
+ const hint = state.panel === 'agents'
+ ? ' \u2191\u2193 navigate enter detail tab switch [r]efresh [w]ake [q]uit'
+ : ' \u2191\u2193 navigate tab switch [r]efresh [q]uit';
+ process.stdout.write(ansi.dim(hint) + '\n');
+}
+
+function renderAgentsList(cols, maxRows) {
+ const agents = getAgentsList();
+ if (agents.length === 0) { process.stdout.write(ansi.dim(' (no agents)\n')); return; }
+
+ const nameW = Math.min(22, Math.floor(cols * 0.25));
+ const roleW = Math.min(16, Math.floor(cols * 0.15));
+ const statusW = 12;
+ process.stdout.write(ansi.dim(` ${pad('Name', nameW)} ${pad('Role', roleW)} ${pad('Status', statusW)} Last Seen\n`));
+
+ // Ensure cursor is visible
+ if (state.cursorAgent >= agents.length) state.cursorAgent = agents.length - 1;
+ if (state.cursorAgent < 0) state.cursorAgent = 0;
+
+ const listRows = maxRows - 1; // minus header
+ // Scroll window
+ let start = 0;
+ if (state.cursorAgent >= start + listRows) start = state.cursorAgent - listRows + 1;
+ if (state.cursorAgent < start) start = state.cursorAgent;
+
+ for (let i = start; i < Math.min(agents.length, start + listRows); i++) {
+ const a = agents[i];
+ const selected = i === state.cursorAgent;
+ const name = pad(truncate(a.name, nameW), nameW);
+ const role = pad(truncate(a.role, roleW), roleW);
+ const st = statusColor(a.status || 'unknown');
+ const stPad = pad(st, statusW + 9);
+ const lastSeen = a.last_seen ? ansi.dim(timeSince(a.last_seen)) : ansi.dim('never');
+ const line = ` ${name} ${role} ${stPad} ${lastSeen}`;
+ process.stdout.write(selected ? ansi.inverse(stripAnsi(line).padEnd(cols)) + '\n' : line + '\n');
+ }
+
+ if (agents.length > listRows) {
+ process.stdout.write(ansi.dim(` ${agents.length} total, showing ${start + 1}-${Math.min(agents.length, start + listRows)}\n`));
+ }
+}
+
+function renderTasksList(cols, maxRows) {
+ const tasks = getTasksList();
+ if (tasks.length === 0) { process.stdout.write(ansi.dim(' (no tasks)\n')); return; }
+
+ const idW = 5;
+ const titleW = Math.min(35, Math.floor(cols * 0.35));
+ const statusW = 14;
+ const assignW = 16;
+ process.stdout.write(ansi.dim(` ${pad('ID', idW)} ${pad('Title', titleW)} ${pad('Status', statusW)} ${pad('Assigned', assignW)}\n`));
+
+ if (state.cursorTask >= tasks.length) state.cursorTask = tasks.length - 1;
+ if (state.cursorTask < 0) state.cursorTask = 0;
+
+ const listRows = maxRows - 1;
+ let start = 0;
+ if (state.cursorTask >= start + listRows) start = state.cursorTask - listRows + 1;
+ if (state.cursorTask < start) start = state.cursorTask;
+
+ for (let i = start; i < Math.min(tasks.length, start + listRows); i++) {
+ const t = tasks[i];
+ const selected = i === state.cursorTask;
+ const id = pad(String(t.id || ''), idW);
+ const title = pad(truncate(t.title, titleW), titleW);
+ const st = statusColor(t.status || '');
+ const stPad = pad(st, statusW + 9);
+ const assigned = pad(truncate(t.assigned_to || '-', assignW), assignW);
+ const line = ` ${id} ${title} ${stPad} ${assigned}`;
+ process.stdout.write(selected ? ansi.inverse(stripAnsi(line).padEnd(cols)) + '\n' : line + '\n');
+ }
+}
+
+// --- Agent Detail View ---
+
+function renderAgentDetail() {
+ const { cols, rows } = getTermSize();
+ ansi.clear();
+
+ const agent = state.selectedAgent;
+ if (!agent) { state.view = 'dashboard'; renderDashboard(); return; }
+
+ // Header
+ process.stdout.write(ansi.bgBlue(pad(` ${agent.name} `, cols)) + '\n');
+ process.stdout.write(` Role: ${ansi.cyan(agent.role || '-')} Status: ${statusColor(agent.status || 'unknown')} ${ansi.dim(agent.last_activity || '')}\n`);
+
+ // Sessions
+ process.stdout.write('\n' + ansi.bold(ansi.cyan(' SESSIONS')) + '\n');
+
+ const sessions = state.agentSessions?.sessions || [];
+ if (state.agentSessions?._error) {
+ process.stdout.write(ansi.dim(` (unavailable: ${state.agentSessions._error})\n`));
+ } else if (sessions.length === 0) {
+ process.stdout.write(ansi.dim(' (no sessions found)\n'));
+ } else {
+ for (let i = 0; i < Math.min(sessions.length, 5); i++) {
+ const s = sessions[i];
+ const selected = i === state.transcriptSessionIdx;
+ const active = s.active ? ansi.green('*') : ' ';
+ const age = s.startTime ? timeSince(s.startTime) : '';
+ const cost = s.estimatedCost != null ? `$${s.estimatedCost.toFixed(2)}` : '';
+ const model = s.model || '';
+ const branch = (s.flags || [])[0] || '';
+ const prompt = truncate(s.lastUserPrompt || '', Math.max(20, cols - 70));
+ const line = ` ${active} ${pad(truncate(s.id || '', 12), 12)} ${pad(model, 18)} ${pad(age, 8)} ${pad(cost, 8)} ${ansi.dim(branch)}`;
+ process.stdout.write(selected ? ansi.inverse(stripAnsi(line).padEnd(cols)) + '\n' : line + '\n');
+ }
+ }
+
+ // Transcript
+ process.stdout.write('\n' + ansi.bold(ansi.magenta(' CHAT')) + '\n');
+
+ const transcript = state.agentTranscript?.messages || [];
+ if (state.agentTranscript?._error) {
+ process.stdout.write(ansi.dim(` (unavailable: ${state.agentTranscript._error})\n`));
+ } else if (transcript.length === 0) {
+ process.stdout.write(ansi.dim(' (no messages — press enter on a session to load)\n'));
+ } else {
+ const chatRows = Math.max(4, rows - 16);
+ const messages = [];
+ for (const msg of transcript) {
+ const role = msg.role || 'unknown';
+ for (const part of (msg.parts || [])) {
+ if (part.type === 'text' && part.text) {
+ messages.push({ role, text: part.text });
+ } else if (part.type === 'tool_use') {
+ messages.push({ role, text: ansi.dim(`[tool: ${part.name || part.id || '?'}]`) });
+ } else if (part.type === 'tool_result') {
+ const preview = typeof part.content === 'string' ? truncate(part.content, 80) : '[result]';
+ messages.push({ role, text: ansi.dim(`[result: ${preview}]`) });
+ }
+ }
+ }
+
+ // Scroll from bottom
+ const visible = messages.slice(-(chatRows + state.transcriptScroll), messages.length - state.transcriptScroll || undefined);
+ for (const m of visible.slice(-chatRows)) {
+ const roleLabel = m.role === 'user' ? ansi.green('you') : m.role === 'assistant' ? ansi.cyan('ai ') : ansi.dim(pad(m.role, 3));
+ const lines = m.text.split('\n');
+ const firstLine = truncate(lines[0], cols - 8);
+ process.stdout.write(` ${roleLabel} ${firstLine}\n`);
+ // Show continuation lines (up to 2 more)
+ for (let j = 1; j < Math.min(lines.length, 3); j++) {
+ process.stdout.write(` ${truncate(lines[j], cols - 8)}\n`);
+ }
+ }
+ }
+
+ // Footer
+ process.stdout.write('\n');
+ if (state.actionMessage) process.stdout.write(ansi.green(` ${state.actionMessage}\n`));
+ process.stdout.write(ansi.dim(' \u2191\u2193 sessions enter load chat pgup/pgdn scroll esc back [q]uit') + '\n');
}
// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
+let baseUrl, apiKey, cookie, refreshMs;
+
async function main() {
const flags = parseArgs(process.argv.slice(2));
@@ -321,21 +428,29 @@ async function main() {
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
+Keys (Dashboard):
+ up/down Navigate agents or tasks list
+ enter Open agent detail (sessions + chat)
+ tab Switch between agents and tasks panels
+ r Refresh now
+ w Wake first sleeping agent
+ q/Esc Quit
+
+Keys (Agent Detail):
+ up/down Navigate sessions
+ enter Load chat transcript for selected session
+ pgup/pgdn Scroll chat
+ esc Back to dashboard
+ q 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);
+ baseUrl = flags.url ? String(flags.url) : profile.url;
+ apiKey = flags['api-key'] ? String(flags['api-key']) : profile.apiKey;
+ cookie = profile.cookie;
+ refreshMs = Number(flags.refresh || 5000);
// Raw mode for keyboard input
if (process.stdin.isTTY) {
@@ -347,11 +462,7 @@ Keys:
ansi.hideCursor();
let running = true;
- let data = { health: {}, agents: {}, tasks: {}, tokens: {} };
- let actionMessage = '';
- let selectedPanel = 'agents';
- // Graceful shutdown
function cleanup() {
running = false;
ansi.showCursor();
@@ -361,75 +472,174 @@ Keys:
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
+ function render() {
+ if (state.view === 'dashboard') renderDashboard();
+ else if (state.view === 'agent-detail') renderAgentDetail();
+ }
+
// 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);
+
+ // Global keys
+ if (key.name === 'q') { cleanup(); return; }
+ if (key.name === 'c' && key.ctrl) { cleanup(); return; }
+
+ if (state.view === 'dashboard') {
+ await handleDashboardKey(key, render);
+ } else if (state.view === 'agent-detail') {
+ await handleAgentDetailKey(key, render);
}
});
- 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...';
+ // Initial load
+ state.actionMessage = 'Loading...';
render();
- data = await fetchDashboardData(baseUrl, apiKey, cookie);
- actionMessage = '';
+ state.data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ state.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
+ if (state.view === 'dashboard') {
+ state.data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ if (state.actionMessage === '') render();
+ }
}
}
+async function handleDashboardKey(key, render) {
+ if (key.name === 'escape') { cleanup(); return; }
+
+ if (key.name === 'tab') {
+ state.panel = state.panel === 'agents' ? 'tasks' : 'agents';
+ render();
+ return;
+ }
+
+ // Also support a/t to switch panels
+ if (key.name === 'a') { state.panel = 'agents'; render(); return; }
+ if (key.name === 't') { state.panel = 'tasks'; render(); return; }
+
+ if (key.name === 'up') {
+ if (state.panel === 'agents') state.cursorAgent = Math.max(0, state.cursorAgent - 1);
+ else state.cursorTask = Math.max(0, state.cursorTask - 1);
+ render();
+ return;
+ }
+
+ if (key.name === 'down') {
+ if (state.panel === 'agents') {
+ const max = getAgentsList().length - 1;
+ state.cursorAgent = Math.min(max, state.cursorAgent + 1);
+ } else {
+ const max = getTasksList().length - 1;
+ state.cursorTask = Math.min(max, state.cursorTask + 1);
+ }
+ render();
+ return;
+ }
+
+ if (key.name === 'return' && state.panel === 'agents') {
+ const agents = getAgentsList();
+ if (agents.length === 0) return;
+ state.selectedAgent = agents[state.cursorAgent];
+ state.view = 'agent-detail';
+ state.transcriptSessionIdx = 0;
+ state.transcriptScroll = 0;
+ state.agentTranscript = null;
+ state.actionMessage = 'Loading sessions...';
+ render();
+ state.agentSessions = await fetchAgentSessions(baseUrl, apiKey, cookie, state.selectedAgent.name);
+ state.actionMessage = '';
+ render();
+ return;
+ }
+
+ if (key.name === 'r') {
+ state.actionMessage = 'Refreshing...';
+ render();
+ state.data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ state.actionMessage = 'Refreshed';
+ render();
+ setTimeout(() => { state.actionMessage = ''; render(); }, 2000);
+ return;
+ }
+
+ if (key.name === 'w') {
+ const agents = state.data.agents?.agents || [];
+ const sleeping = agents.filter(a => a.status === 'sleeping' || a.status === 'idle' || a.status === 'offline');
+ if (sleeping.length === 0) { state.actionMessage = 'No agents to wake'; render(); return; }
+ state.actionMessage = 'Waking agent...';
+ render();
+ const target = sleeping[0];
+ const result = await api(baseUrl, apiKey, cookie, 'POST', `/api/agents/${target.id}/wake`);
+ state.actionMessage = result?._error ? `Wake failed: ${result._error}` : `Woke agent: ${target.name}`;
+ render();
+ state.data = await fetchDashboardData(baseUrl, apiKey, cookie);
+ render();
+ setTimeout(() => { state.actionMessage = ''; render(); }, 3000);
+ }
+}
+
+async function handleAgentDetailKey(key, render) {
+ if (key.name === 'escape') {
+ state.view = 'dashboard';
+ state.selectedAgent = null;
+ state.agentSessions = null;
+ state.agentTranscript = null;
+ render();
+ return;
+ }
+
+ const sessions = state.agentSessions?.sessions || [];
+
+ if (key.name === 'up') {
+ state.transcriptSessionIdx = Math.max(0, state.transcriptSessionIdx - 1);
+ render();
+ return;
+ }
+
+ if (key.name === 'down') {
+ state.transcriptSessionIdx = Math.min(Math.max(0, sessions.length - 1), state.transcriptSessionIdx + 1);
+ render();
+ return;
+ }
+
+ if (key.name === 'return') {
+ if (sessions.length === 0) return;
+ const session = sessions[state.transcriptSessionIdx];
+ if (!session?.id) return;
+ state.actionMessage = 'Loading chat...';
+ state.transcriptScroll = 0;
+ render();
+ state.agentTranscript = await fetchTranscript(baseUrl, apiKey, cookie, session.id, 20);
+ state.actionMessage = '';
+ render();
+ return;
+ }
+
+ // Page up/down for chat scroll
+ if (key.name === 'pageup' || (key.shift && key.name === 'up')) {
+ state.transcriptScroll = Math.min(state.transcriptScroll + 5, 100);
+ render();
+ return;
+ }
+ if (key.name === 'pagedown' || (key.shift && key.name === 'down')) {
+ state.transcriptScroll = Math.max(0, state.transcriptScroll - 5);
+ render();
+ return;
+ }
+}
+
+function cleanup() {
+ ansi.showCursor();
+ ansi.exitAltScreen();
+ process.exit(0);
+}
+
main().catch(err => {
ansi.showCursor();
ansi.exitAltScreen();