diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 3478df29c..52ad7176a 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { healthCommand } from "../../commands/health.js"; import { sessionsCommand } from "../../commands/sessions.js"; import { statusCommand } from "../../commands/status.js"; +import { monitorCommand } from "../../commands/monitor.command.js"; import { setVerbose } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; @@ -77,6 +78,28 @@ export function registerStatusHealthSessionsCommands(program: Command) { }); }); + program + .command("monitor") + .description("Live TUI dashboard for monitoring Moltbot status") + .option("--interval ", "Refresh interval in milliseconds", "2000") + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + ["moltbot monitor", "Start the live monitor."], + ["moltbot monitor --interval 5000", "Update every 5 seconds."], + ])}\n`, + ) + .action(async (opts) => { + const interval = parseTimeoutMs(opts.interval); + if (interval === null) { + return; + } + await runCommandWithRuntime(defaultRuntime, async () => { + await monitorCommand({ intervalMs: interval }, defaultRuntime); + }); + }); + program .command("health") .description("Fetch health from the running gateway") diff --git a/src/commands/monitor.command.ts b/src/commands/monitor.command.ts new file mode 100644 index 000000000..290d56fe9 --- /dev/null +++ b/src/commands/monitor.command.ts @@ -0,0 +1,9 @@ +import type { RuntimeEnv } from "../runtime.js"; +import { runMonitorTui } from "../tui/monitor.js"; + +export async function monitorCommand( + opts: { intervalMs?: number }, + runtime: RuntimeEnv, +) { + await runMonitorTui(runtime, { intervalMs: opts.intervalMs ?? 2000 }); +} diff --git a/src/tui/monitor.ts b/src/tui/monitor.ts new file mode 100644 index 000000000..bf4ce0641 --- /dev/null +++ b/src/tui/monitor.ts @@ -0,0 +1,109 @@ +import { Container, Text, TUI, ProcessTerminal } from "@mariozechner/pi-tui"; +import { scanStatus } from "../commands/status.scan.js"; +import { theme } from "./theme/theme.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { formatDuration } from "../commands/status.format.js"; +import { resolveUpdateAvailability } from "../commands/status.update.js"; + +export async function runMonitorTui(runtime: RuntimeEnv, opts: { intervalMs: number }) { + const tui = new TUI(new ProcessTerminal()); + const root = new Container(); + + const header = new Text("", 1, 0); + const statusLine = new Text("", 1, 0); + const resourcesLine = new Text("", 1, 0); + const agentsHeader = new Text(theme.header("Agents & Sessions"), 1, 0); + const agentsText = new Text("", 10, 0); // Fixed height for now + const footer = new Text("", 1, 0); + + root.addChild(header); + root.addChild(statusLine); + root.addChild(resourcesLine); + root.addChild(new Text(" ", 1, 0)); // Spacer + root.addChild(agentsHeader); + root.addChild(agentsText); + root.addChild(new Text(" ", 1, 0)); // Spacer + root.addChild(footer); + + tui.addChild(root); + + let running = true; + let lastUpdate = 0; + + const refresh = async () => { + if (!running) return; + try { + const now = Date.now(); + const status = await scanStatus({ json: true, timeoutMs: opts.intervalMs - 200 }, runtime); + + const latency = status.gatewayProbe?.connectLatencyMs + ? formatDuration(status.gatewayProbe.connectLatencyMs) + : "N/A"; + + const gatewayState = status.gatewayReachable + ? theme.success("Online") + : theme.error("Offline"); + + const updateLatency = Date.now() - now; + lastUpdate = now; + + const updates = resolveUpdateAvailability(status.update); + const updateMsg = updates.available ? ` • ${theme.accent("UPDATE AVAILABLE")}` : ""; + + header.setText( + theme.header(`Moltbot Monitor • ${new Date().toLocaleTimeString()}${updateMsg}`), + ); + + statusLine.setText( + `Gateway: ${gatewayState} • Latency: ${latency} • Ver: ${status.gatewaySelf?.version ?? "unknown"} • Mode: ${status.gatewayMode}`, + ); + + const mem = status.memory; + const memStatus = mem ? `${mem.files} files, ${mem.chunks} chunks` : "Disabled/Unknown"; + resourcesLine.setText(`Memory: ${memStatus} • OS: ${status.osSummary.label}`); + + // Agents & Sessions + const agents = status.agentStatus.agents; + const sessions = status.summary.sessions.recent; + + let agentLines: string[] = []; + if (agents.length === 0) { + agentLines.push(theme.dim("No agents found.")); + } else { + agentLines.push(`${agents.length} Agent(s) Active`); + } + + if (sessions.length > 0) { + agentLines.push(""); + agentLines.push("Recent Sessions:"); + for (const s of sessions.slice(0, 5)) { + agentLines.push(`• ${s.key} (${s.model ?? "default"}) - ${formatDuration(s.age)} ago`); + } + } else { + agentLines.push(""); + agentLines.push(theme.dim("No active sessions.")); + } + + agentsText.setText(agentLines.join("\n")); + + footer.setText(theme.dim(`Update took ${updateLatency}ms. Press Ctrl+C to exit.`)); + + tui.requestRender(); + } catch (err) { + footer.setText(theme.error(`Error: ${err}`)); + } + + if (running) { + setTimeout(refresh, opts.intervalMs); + } + }; + + tui.start(); + refresh(); + + process.on("SIGINT", () => { + running = false; + tui.stop(); + process.exit(0); + }); +}