mo
feat: enterprise capability expansion β app control, browser control, background execution
fce80d1 Download apps/desktop/src/tui.js from aiagentmona/mona-agent: direct link, hf CLI and curl.
- Browser
- Download file 29.7 kB
-
https://huggingface.co/aiagentmona/mona-agent/resolve/main/apps/desktop/src/tui.js
- Command line
-
hf download hf://aiagentmona/mona-agent/apps/desktop/src/tui.js
-
curl -L -o tui.js https://huggingface.co/aiagentmona/mona-agent/resolve/main/apps/desktop/src/tui.js
29.7 kB
| // Terminal dashboard β full-screen TUI for the mona-agent daemon. | |
| // Zero external dependencies β pure ANSI escape codes + Node builtins. | |
| // Multi-OS support (macOS, Linux, Windows Terminal). | |
| // | |
| // Features: | |
| // β’ Connection state machine with animated spinner + reconnect attempts | |
| // β’ Inline login (press l) β paste your agent.mona.expert API key | |
| // β’ Setup / connect guide shown when no API key is saved yet | |
| // β’ Live system metrics, streaming task preview, wrapped activity log | |
| // β’ Adaptive layout: side-by-side on wide terminals, stacked on narrow | |
| // | |
| // Key bindings: | |
| // q / Ctrl+C Quit | |
| // l Login / replace API key | |
| // r Force reconnect | |
| // c Clear activity log | |
| // d Toggle debug info | |
| // / Scroll log | |
| // g / End Jump to end of log (auto-follow on) | |
| // h / ? Show help + connect instructions | |
| import os from 'node:os'; | |
| import { DEFAULTS, CLOUD, PATHS, loadCreds, saveCreds } from './config.js'; | |
| import { verifyKey } from './cloud.js'; | |
| import { AgentDaemon } from './agent.js'; | |
| // ββ Platform detection ββββββββββββββββββββββββββββββββββββββββββββ | |
| const PLATFORM = os.platform(); // 'darwin' | 'linux' | 'win32' | |
| const IS_WIN = PLATFORM === 'win32'; | |
| // ββ ANSI helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ESC = '\x1b['; | |
| const ansi = { | |
| clear: `${ESC}2J${ESC}H`, | |
| clearLine: `${ESC}2K`, | |
| hide: `${ESC}?25l`, | |
| show: `${ESC}?25h`, | |
| bold: `${ESC}1m`, | |
| dim: `${ESC}2m`, | |
| reset: `${ESC}0m`, | |
| reverse: `${ESC}7m`, | |
| moveTo: (r, c) => `${ESC}${r};${c}H`, | |
| fg: { | |
| gray: `${ESC}90m`, | |
| red: `${ESC}31m`, | |
| green: `${ESC}32m`, | |
| yellow: `${ESC}33m`, | |
| blue: `${ESC}34m`, | |
| magenta: `${ESC}35m`, | |
| cyan: `${ESC}36m`, | |
| white: `${ESC}37m`, | |
| bCyan: `${ESC}96m`, | |
| bGreen: `${ESC}92m`, | |
| bYellow: `${ESC}93m`, | |
| bRed: `${ESC}91m`, | |
| bMagenta: `${ESC}95m`, | |
| bWhite: `${ESC}97m`, | |
| }, | |
| bg: { | |
| bCyan: `${ESC}106m`, | |
| bGreen: `${ESC}102m`, | |
| bYellow: `${ESC}103m`, | |
| bRed: `${ESC}101m`, | |
| bMagenta: `${ESC}105m`, | |
| }, | |
| }; | |
| // ββ Box-drawing chars (ASCII-safe fallback for Windows) ββββββββββ | |
| const B = IS_WIN ? { | |
| tl: '+', tr: '+', bl: '+', br: '+', | |
| h: '-', v: '|', | |
| lt: '+', rt: '+', tt: '+', bt: '+', x: '+', | |
| } : { | |
| tl: 'β', tr: 'β', bl: 'β', br: 'β', | |
| h: 'β', v: 'β', | |
| lt: 'β', rt: 'β€', tt: 'β¬', bt: 'β΄', x: 'βΌ', | |
| }; | |
| // ββ Spinner frames (ASCII fallback on Windows) βββββββββββββββββββ | |
| const SPINNER = IS_WIN ? ['|', '/', '-', '\\'] : ['β ', 'β ', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ', 'β ']; | |
| // ββ Status icons ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ICON = { | |
| connected: 'β', | |
| disconnected: 'β', | |
| thinking: 'β', | |
| done: '', | |
| error: '', | |
| tool: '', | |
| arrow: '', | |
| task: 'βΈ', | |
| token: 'Β·', | |
| debug: 'β¦', | |
| }; | |
| const PLATFORM_ICON = { darwin: '', linux: '', win32: '' }; | |
| const PLATFORM_LABEL = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' }; | |
| // ββ Connection states βββββββββββββββββββββββββββββββββββββββββββββ | |
| const CONN = { | |
| connecting: { label: 'connecting', color: 'bYellow' }, | |
| connected: { label: 'connected', color: 'bGreen' }, | |
| reconnecting: { label: 'reconnecting', color: 'bYellow' }, | |
| offline: { label: 'offline', color: 'bRed' }, | |
| }; | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function memBar(used, total, width = 12) { | |
| const pct = Math.min(1, used / total); | |
| const filled = Math.round(pct * width); | |
| const bar = 'β'.repeat(filled) + 'β'.repeat(width - filled); | |
| const color = pct < 0.6 ? ansi.fg.bGreen : pct < 0.85 ? ansi.fg.bYellow : ansi.fg.bRed; | |
| return `${color}${bar}${ansi.reset} ${Math.round(pct * 100)}%`; | |
| } | |
| function fmtBytes(b) { | |
| if (b > 1e9) return (b / 1e9).toFixed(1) + ' GB'; | |
| if (b > 1e6) return (b / 1e6).toFixed(0) + ' MB'; | |
| return (b / 1e3).toFixed(0) + ' KB'; | |
| } | |
| function fmtUptime(secs) { | |
| const d = Math.floor(secs / 86400); | |
| const h = Math.floor((secs % 86400) / 3600); | |
| const m = Math.floor((secs % 3600) / 60); | |
| if (d > 0) return `${d}d ${h}h ${m}m`; | |
| if (h > 0) return `${h}h ${m}m`; | |
| return `${m}m`; | |
| } | |
| function timeStr() { | |
| return new Date().toLocaleTimeString('en-GB', { hour12: false }); | |
| } | |
| function truncate(s, len) { | |
| if (!s) return ''; | |
| if (s.length <= len) return s; | |
| return s.slice(0, len - 1) + 'β¦'; | |
| } | |
| /** Wrap text to width, splitting long words. */ | |
| function wrapText(s, width) { | |
| if (width < 1) return []; | |
| const words = String(s).split(/\s+/).filter(Boolean); | |
| const lines = []; | |
| let line = ''; | |
| for (const word of words) { | |
| let w = word; | |
| while (w.length > width) { | |
| if (line) { lines.push(line); line = ''; } | |
| lines.push(w.slice(0, width)); | |
| w = w.slice(width); | |
| } | |
| if (!line) line = w; | |
| else if (line.length + 1 + w.length <= width) line += ' ' + w; | |
| else { lines.push(line); line = w; } | |
| } | |
| if (line) lines.push(line); | |
| return lines; | |
| } | |
| function localIP() { | |
| for (const ifaces of Object.values(os.networkInterfaces())) { | |
| for (const iface of ifaces || []) { | |
| if (iface.family === 'IPv4' && !iface.internal) return iface.address; | |
| } | |
| } | |
| return 'β'; | |
| } | |
| // ββ Dashboard class βββββββββββββββββββββββββββββββββββββββββββββββ | |
| export class Dashboard { | |
| #out = process.stdout; | |
| #logs = []; | |
| #maxLogs = 500; | |
| #scrollOffset = 0; | |
| #showDebug = false; | |
| #showHelp = false; | |
| #reconnectAttempts = 0; | |
| #disconnectedAt = null; | |
| #frame = 0; | |
| #inputMode = null; // 'login' | null | |
| #inputBuf = ''; | |
| #inputLabel = ''; | |
| #state = { | |
| conn: CONN.offline, | |
| agentId: null, | |
| host: os.hostname(), | |
| task: null, | |
| stats: { tasks: 0, tokens: 0, toolCalls: 0, errors: 0 }, | |
| metrics: null, | |
| }; | |
| #renderTimer = null; | |
| #agent = null; | |
| #stdin = null; | |
| constructor(agent, { out = process.stdout, setup = false, stdin = process.stdin } = {}) { | |
| this.#out = out; | |
| this.#stdin = stdin; | |
| this.#agent = agent || null; | |
| const creds = loadCreds(); | |
| this.#state.agentId = agent?.creds?.agentId || creds?.agentId || null; | |
| this.#state.conn = agent ? CONN.connecting : (creds ? CONN.offline : CONN.offline); | |
| } | |
| start() { | |
| if (!this.#out.isTTY) { | |
| process.stderr.write('TUI requires a terminal (TTY). Use "mona-agent start" for headless mode.\n'); | |
| process.exit(1); | |
| } | |
| this.#out.write(ansi.hide); | |
| if (this.#stdin.isTTY) { | |
| this.#stdin.setRawMode?.(true); | |
| this.#stdin.resume(); | |
| this.#stdin.setEncoding('utf8'); | |
| } | |
| this.#stdin.on('data', (key) => this.#onKey(key)); | |
| this.#out.on?.('resize', () => this.#render()); | |
| if (this.#agent) this.#wireAgent(this.#agent); | |
| else if (loadCreds()) this.#log('info', 'API key found β press r to start the agent'); | |
| else this.#log('info', 'No API key saved β press l to log in, or run: mona-agent login'); | |
| this.#renderTimer = setInterval(() => this.#render(), 200); | |
| this.#render(); | |
| this.#log('info', `Dashboard started on ${PLATFORM_LABEL[PLATFORM]}`); | |
| return this; | |
| } | |
| // ββ Agent wiring ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #spawnAgent(creds) { | |
| this.#agent?.close?.(); | |
| this.#agent = new AgentDaemon(creds); | |
| this.#wireAgent(this.#agent); | |
| this.#agent.start(); | |
| } | |
| #wireAgent(agent) { | |
| this.#state.conn = CONN.connecting; | |
| agent.on('connected', () => { | |
| this.#state.conn = CONN.connected; | |
| this.#reconnectAttempts = 0; | |
| this.#disconnectedAt = null; | |
| this.#log('info', `Connected to ${CLOUD.base}`); | |
| }); | |
| agent.on('disconnected', (code) => { | |
| this.#state.conn = CONN.reconnecting; | |
| this.#reconnectAttempts++; | |
| this.#disconnectedAt = Date.now(); | |
| this.#log('warn', `Disconnected (code=${code}), reconnecting (attempt ${this.#reconnectAttempts})...`); | |
| }); | |
| agent.on('auth-failed', () => { | |
| this.#state.conn = CONN.offline; | |
| this.#log('error', 'Cloud rejected credentials β press l to re-login'); | |
| }); | |
| agent.on('metrics', (m) => { | |
| this.#state.metrics = m; | |
| }); | |
| agent.on('task:start', (t) => { | |
| this.#state.task = { text: t.task, tail: '', tokens: 0, startedAt: Date.now() }; | |
| this.#log('task', `Task: "${truncate(t.task, 60)}"`); | |
| }); | |
| agent.on('task:token', (delta) => { | |
| if (!this.#state.task) return; | |
| this.#state.task.tokens++; | |
| this.#state.task.tail = (this.#state.task.tail + delta).slice(-600); | |
| }); | |
| agent.on('task:done', (result) => { | |
| const t = this.#state.task; | |
| const elapsed = t ? ((Date.now() - t.startedAt) / 1000).toFixed(1) : '?'; | |
| this.#state.stats.tasks++; | |
| this.#state.stats.tokens += result.tokens; | |
| this.#state.task = null; | |
| this.#log('done', `Complete (${result.tokens} tok, ${elapsed}s)`); | |
| }); | |
| agent.on('task:error', (err) => { | |
| this.#state.stats.errors++; | |
| this.#state.task = null; | |
| this.#log('error', `Task failed: ${err.message}`); | |
| }); | |
| agent.on('tool:start', (name) => { | |
| this.#log('tool', `Tool: ${name}`); | |
| }); | |
| agent.on('tool:done', (name, result) => { | |
| this.#state.stats.toolCalls++; | |
| if (result.error) this.#log('warn', `Tool ${name}: ${result.error}`); | |
| else this.#log('tool', `${name} done`); | |
| }); | |
| agent.on('error', (err) => { | |
| this.#log('error', err.message); | |
| }); | |
| } | |
| // ββ Log management ββββββββββββββββββββββββββββββββββββββββββββββ | |
| #log(type, msg) { | |
| this.#logs.push({ time: timeStr(), type, msg }); | |
| if (this.#logs.length > this.#maxLogs) this.#logs.shift(); | |
| // Auto-follow: new activity snaps the view back to the latest entry. | |
| // (Scrolling up to read history is fine β the next event brings you back.) | |
| this.#scrollOffset = 0; | |
| } | |
| // ββ Input handling ββββββββββββββββββββββββββββββββββββββββββββββ | |
| #onKey(key) { | |
| // Input mode (login prompt): accumulate until Enter | |
| if (this.#inputMode) { | |
| if (key === '\r' || key === '\n') { this.#submitLogin(); return; } | |
| if (key === '\x7f' || key === '\b') { this.#inputBuf = this.#inputBuf.slice(0, -1); return; } | |
| if (key === '\x03') { this.#cancelInput(); return; } | |
| if (key.length === 1 && key >= ' ') { | |
| this.#inputBuf = (this.#inputBuf + key).slice(-512); | |
| } | |
| return; | |
| } | |
| // Help overlay dismiss | |
| if (this.#showHelp) { this.#showHelp = false; return; } | |
| switch (key) { | |
| case 'q': | |
| case '\x03': // Ctrl+C | |
| this.stop(); | |
| this.#agent?.close(); | |
| process.exit(0); | |
| break; | |
| case 'l': | |
| this.#startLogin(); | |
| break; | |
| case 'c': | |
| this.#logs = []; | |
| this.#scrollOffset = 0; | |
| this.#log('info', 'Log cleared'); | |
| break; | |
| case 'r': | |
| this.#log('info', 'Forcing reconnect...'); | |
| if (this.#agent) { | |
| this.#agent.close(); | |
| setTimeout(() => this.#agent?.start(), 400); | |
| } else if (loadCreds()) { | |
| this.#log('info', 'Starting agent...'); | |
| this.#spawnAgent(loadCreds()); | |
| } else { | |
| this.#log('warn', 'No API key β press l to log in first'); | |
| } | |
| break; | |
| case 'd': | |
| this.#showDebug = !this.#showDebug; | |
| this.#log('info', `Debug ${this.#showDebug ? 'on' : 'off'}`); | |
| break; | |
| case 'h': | |
| case '?': | |
| this.#showHelp = true; | |
| break; | |
| case '\x1b[A': // Up | |
| this.#scrollOffset = Math.min(this.#scrollOffset + 1, Math.max(0, this.#logs.length - 5)); | |
| break; | |
| case '\x1b[B': // Down | |
| this.#scrollOffset = Math.max(0, this.#scrollOffset - 1); | |
| break; | |
| case 'g': | |
| case '\x1b[F': // End | |
| this.#scrollOffset = 0; | |
| break; | |
| } | |
| } | |
| #startLogin() { | |
| this.#inputMode = 'login'; | |
| this.#inputBuf = ''; | |
| this.#inputLabel = 'Paste your agent.mona.expert API key, then press Enter:'; | |
| this.#log('info', 'Login prompt β paste API key (Esc/Ctrl+C cancels)'); | |
| } | |
| #cancelInput() { | |
| this.#inputMode = null; | |
| this.#inputBuf = ''; | |
| this.#log('info', 'Login cancelled'); | |
| } | |
| async #submitLogin() { | |
| const key = this.#inputBuf.trim(); | |
| this.#inputMode = null; | |
| this.#inputBuf = ''; | |
| if (!key) { this.#log('warn', 'No key entered β login cancelled'); return; } | |
| this.#log('info', 'Verifying key with ' + CLOUD.base + '...'); | |
| try { | |
| const info = await verifyKey(key); | |
| saveCreds({ apiKey: key, agentId: info.agentId || null }); | |
| this.#state.agentId = info.agentId || null; | |
| this.#log('done', `Logged in as ${info.agentId || 'agent'}`); | |
| this.#spawnAgent({ apiKey: key, agentId: info.agentId || null }); | |
| } catch (err) { | |
| this.#log('error', `Login failed: ${err.message}`); | |
| } | |
| } | |
| // ββ Rendering engine ββββββββββββββββββββββββββββββββββββββββββββ | |
| #render() { | |
| const W = this.#out.columns || 80; | |
| const H = this.#out.rows || 24; | |
| if (W < 40 || H < 12) return; | |
| this.#frame++; | |
| const buf = []; | |
| const w = (s) => buf.push(s); | |
| w(ansi.clear); | |
| // ββ Help overlay ββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (this.#showHelp) { | |
| this.#renderHelp(w, W, H); | |
| this.#out.write(buf.join('')); | |
| return; | |
| } | |
| // ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const title = `${ansi.bold}${ansi.fg.bCyan}mona-agent${ansi.reset} ${ansi.dim}v${DEFAULTS.version}${ansi.reset} ${PLATFORM_ICON[PLATFORM] || ''}`; | |
| const chips = [ | |
| this.#state.agentId | |
| ? `${ansi.dim}${ansi.fg.cyan}β${ansi.reset} ${this.#state.agentId}` | |
| : `${ansi.dim}${ansi.fg.gray}β${ansi.reset} ${ansi.dim}no agent${ansi.reset}`, | |
| this.#connChip(), | |
| ].join(` ${ansi.fg.gray}${B.v}${ansi.reset} `); | |
| const titleLen = this.#stripAnsi(title).length; | |
| const chipsLen = this.#stripAnsi(chips).length; | |
| const pad = Math.max(1, W - titleLen - chipsLen - 6); | |
| w(`${ansi.fg.gray}${B.tl}${B.h}${ansi.reset}${title}${' '.repeat(pad)}${chips}${' '.repeat(1)}${ansi.fg.gray}${B.h}${B.tr}${ansi.reset}\n`); | |
| // ββ Debug bar βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (this.#showDebug) { | |
| const debugInfo = `${ansi.dim}Cloud: ${CLOUD.base} | WS: ${CLOUD.wsUrl} | Creds: ${PATHS.creds} | Reconnects: ${this.#reconnectAttempts}${ansi.reset}`; | |
| w(`${ansi.fg.gray}${B.v}${ansi.reset} ${this.#padRight(debugInfo, W - 2)} ${ansi.fg.gray}${B.v}${ansi.reset}\n`); | |
| } | |
| // ββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const bodyH = H - 4 - (this.#showDebug ? 1 : 0); | |
| const wide = W >= 78; | |
| const panelH = wide ? bodyH : Math.max(4, Math.floor((bodyH - 1) / 2)); | |
| const sysLines = this.#renderSystem(wide ? Math.floor(W * 0.38) - 2 : W - 2, panelH); | |
| const taskLines = this.#renderTask(wide ? Math.floor(W * 0.38) - 2 : W - 2, panelH); | |
| // The log panel draws a header row and a bottom border, so only | |
| // (panel height β 2) entries are actually visible. Size the window | |
| // accordingly or the two newest entries get cut off every render. | |
| const logViewH = wide ? bodyH - 2 : Math.max(1, bodyH - panelH - 2); | |
| const logLines = this.#renderLog(wide ? W - Math.floor(W * 0.38) - 3 : W - 2, logViewH); | |
| if (wide) { | |
| const leftW = Math.floor(W * 0.38); | |
| const rightW = W - leftW - 3; | |
| for (let row = 0; row < bodyH; row++) { | |
| const left = this.#panelRow(sysLines, taskLines, row, leftW, panelH); | |
| const right = this.#panelRowLog(logLines, row, rightW, bodyH); | |
| w(`${left} ${right}\n`); | |
| } | |
| } else { | |
| // Narrow: System+Task stacked, then Activity | |
| const activityTop = panelH + 1; | |
| for (let row = 0; row < bodyH; row++) { | |
| if (row < panelH) { | |
| w(`${this.#panelRow(sysLines, taskLines, row, W, panelH)}\n`); | |
| } else if (row === activityTop - 1) { | |
| w(`${ansi.fg.gray}${B.tl}${B.h} ${ansi.fg.bYellow}Activity${ansi.reset} ${ansi.fg.gray}${B.h.repeat(Math.max(0, W - 12))}${B.tr}${ansi.reset}\n`); | |
| } else if (row < bodyH - 1) { | |
| const logRow = logLines[row - activityTop] || ''; | |
| w(`${ansi.fg.gray}${B.v}${ansi.reset} ${this.#padRight(logRow, W - 2)} ${ansi.fg.gray}${B.v}${ansi.reset}\n`); | |
| } else { | |
| w(`${ansi.fg.gray}${B.bl}${B.h.repeat(W)}${B.br}${ansi.reset}\n`); | |
| } | |
| } | |
| } | |
| // ββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const wideFooter = `${ansi.dim}q${ansi.reset} quit ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}l${ansi.reset} login ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}c${ansi.reset} clear ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}r${ansi.reset} reconnect ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}d${ansi.reset} debug ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}h${ansi.reset} help`; | |
| const narrowFooter = `${ansi.dim}q${ansi.reset} quit ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}l${ansi.reset} login ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}r${ansi.reset} reconnect ${ansi.fg.gray}Β·${ansi.reset} ${ansi.dim}h${ansi.reset} help`; | |
| const keys = W >= 78 ? wideFooter : narrowFooter; | |
| const taskStatus = this.#state.task | |
| ? `${ansi.fg.bYellow}${SPINNER[this.#frame % SPINNER.length]} thinking (${this.#state.task.tokens} tok)${ansi.reset}` | |
| : this.#state.conn === CONN.connected | |
| ? `${ansi.fg.bGreen}${ICON.connected} online${ansi.reset}` | |
| : `${ansi.dim}${this.#connChip()}${ansi.reset}`; | |
| const footerPad = W - this.#stripAnsi(keys).length - this.#stripAnsi(taskStatus).length - 4; | |
| w(`${ansi.fg.gray}${B.lt}${B.h.repeat(W)}${B.rt}${ansi.reset}\n`); | |
| w(`${ansi.fg.gray}${B.v}${ansi.reset} ${keys}${' '.repeat(Math.max(1, footerPad))}${taskStatus} ${ansi.fg.gray}${B.v}${ansi.reset}\n`); | |
| w(`${ansi.fg.gray}${B.bl}${B.h.repeat(W)}${B.br}${ansi.reset}\n`); | |
| // ββ Login input line ββββββββββββββββββββββββββββββββββββββββββ | |
| if (this.#inputMode === 'login') { | |
| const masked = this.#inputBuf.replace(/./g, 'β’'); | |
| const prompt = `${ansi.bold}${ansi.fg.bCyan}${this.#inputLabel}${ansi.reset} ${masked}${ansi.bg.bCyan} ${ansi.reset}`; | |
| w(`${ansi.moveTo(H, 1)}${this.#padRight(prompt, W - 1)}`); | |
| } | |
| this.#out.write(buf.join('')); | |
| } | |
| #connChip() { | |
| const c = this.#state.conn; | |
| const color = ansi.fg[c.color] || ansi.fg.gray; | |
| if (c === CONN.offline) { | |
| return `${color}${ICON.disconnected} offline${ansi.reset}`; | |
| } | |
| const icon = c === CONN.connected ? ICON.connected : SPINNER[this.#frame % SPINNER.length]; | |
| let label = c.label; | |
| if (c === CONN.reconnecting) { | |
| const secs = this.#disconnectedAt ? Math.floor((Date.now() - this.#disconnectedAt) / 1000) : 0; | |
| label = `reconnecting ${secs}s`; | |
| } | |
| return `${color}${icon} ${label}${ansi.reset}`; | |
| } | |
| // ββ Panel row compositors βββββββββββββββββββββββββββββββββββββββ | |
| #panelRow(sysLines, taskLines, row, width, height) { | |
| const sysH = Math.min(sysLines.length + 2, Math.floor(height * 0.55)); | |
| const taskH = height - sysH; | |
| if (row === 0) { | |
| return `${ansi.fg.gray}${B.tl}${B.h} ${ansi.fg.bCyan}System${ansi.reset} ${ansi.fg.gray}${B.h.repeat(Math.max(0, width - 10))}${B.tr}${ansi.reset}`; | |
| } | |
| if (row < sysH - 1) { | |
| return `${ansi.fg.gray}${B.v}${ansi.reset} ${this.#padRight(sysLines[row - 1] || '', width - 2)} ${ansi.fg.gray}${B.v}${ansi.reset}`; | |
| } | |
| if (row === sysH - 1) { | |
| return `${ansi.fg.gray}${B.lt}${B.h} ${ansi.fg.bMagenta}Task${ansi.reset} ${ansi.fg.gray}${B.h.repeat(Math.max(0, width - 8))}${B.rt}${ansi.reset}`; | |
| } | |
| if (row < height - 1) { | |
| return `${ansi.fg.gray}${B.v}${ansi.reset} ${this.#padRight(taskLines[row - sysH] || '', width - 2)} ${ansi.fg.gray}${B.v}${ansi.reset}`; | |
| } | |
| return `${ansi.fg.gray}${B.bl}${B.h.repeat(width)}${B.br}${ansi.reset}`; | |
| } | |
| #panelRowLog(logLines, row, width, height) { | |
| if (row === 0) { | |
| return `${ansi.fg.gray}${B.tl}${B.h} ${ansi.fg.bYellow}Activity${ansi.reset} ${ansi.fg.gray}${B.h.repeat(Math.max(0, width - 12))}${B.tr}${ansi.reset}`; | |
| } | |
| if (row === height - 1) { | |
| return `${ansi.fg.gray}${B.bl}${B.h.repeat(width)}${B.br}${ansi.reset}`; | |
| } | |
| return `${ansi.fg.gray}${B.v}${ansi.reset} ${this.#padRight(logLines[row - 1] || '', width - 2)} ${ansi.fg.gray}${B.v}${ansi.reset}`; | |
| } | |
| // ββ Help overlay ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #renderHelp(w, W, H) { | |
| const lines = [ | |
| '', | |
| ` ${ansi.bold}${ansi.fg.bCyan}mona-agent${ansi.reset} v${DEFAULTS.version} ${PLATFORM_ICON[PLATFORM] || ''} β ${ansi.dim}${PLATFORM_LABEL[PLATFORM]} | Node ${process.version}${ansi.reset}`, | |
| '', | |
| ` ${ansi.bold}${ansi.fg.bGreen}Connect your agent${ansi.reset}`, | |
| ` ${ansi.fg.gray}1.${ansi.reset} Get an API key at ${ansi.fg.bCyan}https://agent.mona.expert${ansi.reset}`, | |
| ` ${ansi.fg.gray}2.${ansi.reset} Press ${ansi.fg.bGreen}l${ansi.reset} to paste it here β or run: ${ansi.fg.bCyan}mona-agent login${ansi.reset}`, | |
| ` ${ansi.fg.gray}3.${ansi.reset} Press ${ansi.fg.bGreen}r${ansi.reset} to (re)connect β status shows in the header`, | |
| ` ${ansi.fg.gray}4.${ansi.reset} Send commands from the ${ansi.fg.bCyan}agent.mona.expert${ansi.reset} dashboard`, | |
| ` ${ansi.fg.gray}5.${ansi.reset} Headless mode: ${ansi.fg.bCyan}mona-agent start${ansi.reset} Β· test: ${ansi.fg.bCyan}mona-agent connect${ansi.reset}`, | |
| '', | |
| ` ${ansi.bold}Key Bindings${ansi.reset}`, | |
| '', | |
| ` ${ansi.fg.bGreen}q${ansi.reset} / ${ansi.fg.bGreen}Ctrl+C${ansi.reset} Quit`, | |
| ` ${ansi.fg.bGreen}l${ansi.reset} Login / replace API key`, | |
| ` ${ansi.fg.bGreen}r${ansi.reset} Force reconnect`, | |
| ` ${ansi.fg.bGreen}c${ansi.reset} Clear activity log`, | |
| ` ${ansi.fg.bGreen}d${ansi.reset} Toggle debug info bar`, | |
| ` ${ansi.fg.bGreen}h${ansi.reset} / ${ansi.fg.bGreen}?${ansi.reset} Show this help (any key to dismiss)`, | |
| ` ${ansi.fg.bGreen} / ${ansi.reset} Scroll activity log`, | |
| '', | |
| ` ${ansi.bold}Environment${ansi.reset}`, | |
| ` ${ansi.dim}Cloud:${ansi.reset} ${CLOUD.base}`, | |
| ` ${ansi.dim}WS:${ansi.reset} ${CLOUD.wsUrl}`, | |
| ` ${ansi.dim}Creds:${ansi.reset} ${PATHS.creds}`, | |
| '', | |
| ` ${ansi.dim}Press any key to dismiss${ansi.reset}`, | |
| ]; | |
| const startRow = Math.max(1, Math.floor((H - lines.length) / 2)); | |
| for (let i = 0; i < lines.length; i++) { | |
| w(`${ansi.moveTo(startRow + i, 1)}${lines[i]}`); | |
| } | |
| } | |
| // ββ Panel renderers βββββββββββββββββββββββββββββββββββββββββββββ | |
| #renderSystem(width, height) { | |
| const m = this.#state.metrics || {}; | |
| const totalMem = m.mem?.total || os.totalmem(); | |
| const freeMem = m.mem?.free || os.freemem(); | |
| const usedMem = totalMem - freeMem; | |
| const load = m.cpuLoad || os.loadavg(); | |
| const barW = Math.max(6, Math.min(width - 18, 22)); | |
| const lines = [ | |
| `${ansi.fg.gray}Host${ansi.reset} ${ansi.fg.bWhite}${truncate(os.hostname(), width - 8)}${ansi.reset}`, | |
| `${ansi.fg.gray}OS${ansi.reset} ${PLATFORM_LABEL[PLATFORM]} ${os.arch()}`, | |
| `${ansi.fg.gray}CPUs${ansi.reset} ${os.cpus().length} cores`, | |
| `${ansi.fg.gray}Mem${ansi.reset} ${memBar(usedMem, totalMem, barW)}`, | |
| `${ansi.fg.gray} ${fmtBytes(usedMem)} / ${fmtBytes(totalMem)}${ansi.reset}`, | |
| `${ansi.fg.gray}Load${ansi.reset} ${load.map(v => v.toFixed(2)).join(' ')}`, | |
| `${ansi.fg.gray}IP${ansi.reset} ${ansi.dim}${localIP()}${ansi.reset}`, | |
| `${ansi.fg.gray}Up${ansi.reset} ${fmtUptime(m.uptime || os.uptime())}`, | |
| ]; | |
| while (lines.length < height - 2) lines.push(''); | |
| return lines; | |
| } | |
| #renderTask(width, height) { | |
| const t = this.#state.task; | |
| const s = this.#state.stats; | |
| const rows = height - 2; | |
| // No agent running β show the connect guide | |
| if (!this.#agent) { | |
| const lines = [ | |
| `${ansi.bold}${ansi.fg.bCyan}Connect your agent${ansi.reset}`, | |
| '', | |
| `${ansi.fg.gray}1.${ansi.reset} Get an API key at`, | |
| ` ${ansi.fg.bCyan}agent.mona.expert${ansi.reset}`, | |
| `${ansi.fg.gray}2.${ansi.reset} Press ${ansi.fg.bGreen}l${ansi.reset} to paste it here`, | |
| ` ${ansi.dim}(or run: mona-agent login)${ansi.reset}`, | |
| `${ansi.fg.gray}3.${ansi.reset} Agent connects automatically`, | |
| `${ansi.fg.gray}4.${ansi.reset} Control it from the dashboard`, | |
| '', | |
| `${ansi.dim}Headless: mona-agent start${ansi.reset}`, | |
| ]; | |
| while (lines.length < rows) lines.push(''); | |
| return lines; | |
| } | |
| if (t) { | |
| const elapsed = ((Date.now() - t.startedAt) / 1000).toFixed(0); | |
| const lines = [ | |
| `${ansi.fg.bYellow}${SPINNER[this.#frame % SPINNER.length]} Thinking...${ansi.reset}`, | |
| `${ansi.fg.gray}Tokens${ansi.reset} ${ansi.fg.bWhite}${t.tokens}${ansi.reset} ${ansi.fg.gray}${elapsed}s${ansi.reset}`, | |
| '', | |
| ...wrapText(`${ansi.dim}${t.tail.replace(/\n/g, ' ')}${ansi.reset}`, Math.max(10, width - 2)).slice(0, Math.max(1, rows - 3)), | |
| ]; | |
| while (lines.length < rows) lines.push(''); | |
| return lines; | |
| } | |
| const lines = [ | |
| `${ansi.fg.green}${ICON.done} Idle β waiting for commands${ansi.reset}`, | |
| `${ansi.dim}Control this agent from agent.mona.expert${ansi.reset}`, | |
| '', | |
| `${ansi.fg.gray}Tasks${ansi.reset} ${ansi.fg.bWhite}${s.tasks}${ansi.reset} done`, | |
| `${ansi.fg.gray}Tokens${ansi.reset} ${ansi.fg.bWhite}${s.tokens}${ansi.reset} total`, | |
| `${ansi.fg.gray}Tools${ansi.reset} ${ansi.fg.bWhite}${s.toolCalls}${ansi.reset} calls`, | |
| ]; | |
| if (s.errors > 0) lines.push(`${ansi.fg.bRed}Errors${ansi.reset} ${s.errors}`); | |
| while (lines.length < rows) lines.push(''); | |
| return lines; | |
| } | |
| #renderLog(width, height) { | |
| const lines = []; | |
| const start = Math.max(0, this.#logs.length - height - this.#scrollOffset); | |
| const end = Math.min(this.#logs.length, start + height); | |
| const contentW = Math.max(10, width - 2); | |
| for (let i = start; i < end; i++) { | |
| const entry = this.#logs[i]; | |
| const icon = this.#logIcon(entry.type); | |
| const wrapped = wrapText(entry.msg, Math.max(10, contentW - 11)); | |
| if (!wrapped.length) continue; | |
| lines.push(`${ansi.fg.gray}${entry.time}${ansi.reset} ${icon} ${wrapped[0]}`); | |
| for (let j = 1; j < wrapped.length; j++) lines.push(`${' '.repeat(11)} ${wrapped[j]}`); | |
| } | |
| while (lines.length < height) lines.push(''); | |
| return lines; | |
| } | |
| #logIcon(type) { | |
| switch (type) { | |
| case 'info': return `${ansi.fg.bCyan}${ICON.connected}${ansi.reset}`; | |
| case 'warn': return `${ansi.fg.bYellow}${ansi.reset}`; | |
| case 'error': return `${ansi.fg.bRed}${ICON.error}${ansi.reset}`; | |
| case 'task': return `${ansi.fg.bMagenta}${ICON.task}${ansi.reset}`; | |
| case 'done': return `${ansi.fg.bGreen}${ICON.done}${ansi.reset}`; | |
| case 'tool': return `${ansi.fg.cyan}${ICON.tool}${ansi.reset}`; | |
| default: return `${ansi.fg.gray}Β·${ansi.reset}`; | |
| } | |
| } | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #stripAnsi(s) { | |
| return s.replace(/\x1b\[[0-9;]*m/g, ''); | |
| } | |
| #padRight(s, len) { | |
| const visible = this.#stripAnsi(s).length; | |
| if (visible >= len) return s; | |
| return s + ' '.repeat(len - visible); | |
| } | |
| stop() { | |
| clearInterval(this.#renderTimer); | |
| if (process.stdin.isTTY) process.stdin.setRawMode?.(false); | |
| this.#out.write(ansi.show); | |
| this.#out.write(ansi.clear); | |
| } | |
| } | |