/** * Ink-based TUI dashboard for the pepa runtime. * * Layout: * ┌─────────── status (HP / food / pos / task / connection / paused) ──────────┐ * │ ┌───────── event log ─────────┐ ┌──────── MC chat ──────────┐ │ * │ │ │ │ │ │ * │ └─────────────────────────────┘ └──────────────────────────┘ │ * └─────────────────── command bar (hotkeys + chat input) ─────────────────────┘ * * Hotkeys: * p — pause / resume reflex loop * s — stop bot (sends cmd:stop) * r — request fresh snapshot * c — enter chat mode (type, Enter to send to MC) * a — enter ask-Pi mode (type, Enter to spawn pi -p) * q — quit TUI (bot keeps running) */ import React, { useEffect, useReducer, useState } from "react"; import { render, Box, Text, useApp, useInput } from "ink"; import TextInput from "ink-text-input"; import { createIpcClient } from "./ipc-client.js"; import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js"; type LogEntry = { ts: string; level: string; source: string; text: string }; type ChatEntry = { ts: string; from: string; text: string; kind: string }; type Snapshot = Record; type State = { connectedToBot: boolean; snapshot: Snapshot; logs: LogEntry[]; chat: ChatEntry[]; paused: boolean; piStream: string; piRunning: boolean; proposal: { filename: string | null; body: string | null; total: number } | null; }; type Action = | { type: "ipc-connected" } | { type: "ipc-disconnected" } | { type: "snapshot"; payload: Snapshot } | { type: "log"; payload: LogEntry } | { type: "chat"; payload: { from: string; text: string; kind: string }; ts: string } | { type: "death"; payload: any; ts: string } | { type: "pi-chunk"; payload: { stream: string; text: string } } | { type: "pi-done"; payload: { code: number; durationMs: number } } | { type: "set-paused"; paused: boolean } | { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } } | { type: "proposal"; payload: { filename: string | null; body: string | null; total: number } } | { type: "proposal-close" }; const MAX_LOGS = 200; const MAX_CHAT = 100; function reducer(state: State, action: Action): State { switch (action.type) { case "ipc-connected": return { ...state, connectedToBot: true }; case "ipc-disconnected": return { ...state, connectedToBot: false }; case "snapshot": return { ...state, snapshot: action.payload || {} }; case "log": return { ...state, logs: [...state.logs, action.payload].slice(-MAX_LOGS) }; case "chat": return { ...state, chat: [...state.chat, { ts: action.ts, ...action.payload }].slice(-MAX_CHAT) }; case "death": return { ...state, logs: [ ...state.logs, { ts: action.ts, level: "warn", source: "mc", text: `death at ${JSON.stringify(action.payload?.position ?? null)}` }, ].slice(-MAX_LOGS), }; case "pi-chunk": return { ...state, piRunning: true, piStream: (state.piStream + action.payload.text).slice(-2000) }; case "pi-done": return { ...state, piRunning: false, piStream: state.piStream + `\n[pi done code=${action.payload.code} after ${action.payload.durationMs}ms]\n`, }; case "set-paused": return { ...state, paused: action.paused }; case "hello": return { ...state, snapshot: action.payload.snapshot || {}, logs: action.payload.recentLogs || [], }; case "proposal": return { ...state, proposal: action.payload }; case "proposal-close": return { ...state, proposal: null }; default: return state; } } const STATE_COLOR: Record = { emergency: "red", working: "cyan", recovering: "yellow", planning: "magenta", social: "blue", idle: "gray", }; function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) { const tone = snapshot.connected ? "green" : "red"; const stateName: string = snapshot.runtimeState ?? "?"; const stateColor = STATE_COLOR[stateName] ?? "white"; const reason: string | null = snapshot.noProgressReason ?? null; const lastResult: any = snapshot.lastResult ?? null; const milestone: string | null = snapshot.currentMilestone ?? null; const failuresByCode: Record = snapshot.failuresByCode ?? {}; const failuresStr = Object.entries(failuresByCode) .map(([k, v]) => `${k}:${v}`) .join(" "); return ( {snapshot.connected ? "● MC online" : "○ MC offline"} {" "} {connectedToBot ? "IPC ok" : "IPC down"} {" "} {paused ? ⏸ reflex paused : ▶ reflex live} {" "} state={stateName} user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "} pos= {snapshot.position ? `${snapshot.position.x},${snapshot.position.y},${snapshot.position.z}` : "?"}{" "} day={String(snapshot.isDay ?? "?")} hostiles={snapshot.hostileCount ?? 0} {snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""} {snapshot.pendingProposals ? {` [proposals ${snapshot.pendingProposals}, press y]`} : null} {snapshot.busy ? ( ▸ skill: {snapshot.busy.label} ) : snapshot.activeSkill ? ( last skill: {snapshot.activeSkill} {snapshot.lastReflex?.ts ? ` (${formatAge(snapshot.lastReflex.ts)})` : ""} ) : ( no skill yet )} milestone: {milestone ?? (none — planner_empty?)} {snapshot.curriculum?.plan?.skillId ? ( → suggested: {snapshot.curriculum.plan.skillId} ) : null} {snapshot.curriculum?.inventoryFull ? ( [inventory full] ) : null} {reason ? ( ▲ no-progress: {" "} {reason} ) : null} {lastResult ? ( last result: {lastResult.label} → {lastResult.code ?? (lastResult.ok ? "ok" : "fail")} {lastResult.ts ? ` ${formatAge(lastResult.ts)}` : ""} ) : null} {failuresStr ? ( failures by class: {failuresStr} ) : null} {snapshot.lastEscalation?.ts ? ( last Pi escalation: {formatAge(snapshot.lastEscalation.ts)} ) : null} ); } function formatAge(tsMs: number): string { const ageMs = Date.now() - tsMs; if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`; if (ageMs < 3600_000) return `${Math.floor(ageMs / 60_000)}m ago`; return `${Math.floor(ageMs / 3600_000)}h ago`; } function ProposalPanel({ proposal, onClose, onApprove, }: { proposal: { filename: string | null; body: string | null; total: number }; onClose: () => void; onApprove: () => void; }) { if (!proposal.filename) { return ( No pending proposals. ([Esc] to close) ); } const lines = (proposal.body ?? "").split("\n").slice(0, 30); return ( proposal: {proposal.filename} (total pending: {proposal.total}) {lines.map((line, i) => ( {line} ))} [y]es approve [n]o close — uses npm run propose:apply afterwards ); } function EventLog({ logs }: { logs: LogEntry[] }) { const last = logs.slice(-14); return ( events {last.map((l, i) => ( {l.ts.slice(11, 19)} [{l.source}] {l.text} ))} ); } function ChatPanel({ chat }: { chat: ChatEntry[] }) { const last = chat.slice(-14); return ( MC chat {last.map((c, i) => ( {c.ts?.slice(11, 19) ?? ""} {c.from}: {c.text} ))} ); } function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean }) { if (!piStream && !piRunning) return null; return ( pi (escalation) {piRunning ? "● running" : "○ idle"} {piStream || "(no output yet)"} ); } type Mode = "idle" | "chat" | "ask-pi"; function App() { const { exit } = useApp(); const [state, dispatch] = useReducer(reducer, { connectedToBot: false, snapshot: {}, logs: [], chat: [], paused: false, piStream: "", piRunning: false, proposal: null, }); const [client] = useState(() => createIpcClient()); const [mode, setMode] = useState("idle"); const [inputValue, setInputValue] = useState(""); useEffect(() => { const onConnected = () => dispatch({ type: "ipc-connected" }); const onDisconnected = () => dispatch({ type: "ipc-disconnected" }); const onFrame = (frame: any) => { switch (frame.type) { case EVENT_TYPES.STATUS: dispatch({ type: "snapshot", payload: frame.payload }); break; case EVENT_TYPES.LOG: dispatch({ type: "log", payload: frame.payload }); break; case EVENT_TYPES.CHAT: dispatch({ type: "chat", payload: frame.payload, ts: frame.ts }); break; case EVENT_TYPES.DEATH: dispatch({ type: "death", payload: frame.payload, ts: frame.ts }); break; case EVENT_TYPES.HELLO: dispatch({ type: "hello", payload: frame.payload }); break; case EVENT_TYPES.ASK_PI_CHUNK: dispatch({ type: "pi-chunk", payload: frame.payload }); break; case EVENT_TYPES.ASK_PI_DONE: dispatch({ type: "pi-done", payload: frame.payload }); break; case EVENT_TYPES.PROPOSAL: dispatch({ type: "proposal", payload: frame.payload }); break; } }; (client as any).on("connected", onConnected); (client as any).on("disconnected", onDisconnected); (client as any).on("frame", onFrame); return () => { (client as any).off("connected", onConnected); (client as any).off("disconnected", onDisconnected); (client as any).off("frame", onFrame); client.close(); }; }, [client]); useInput((input, key) => { if (mode !== "idle") return; // text input has its own handling // Proposal panel is open — accept y/n only. if (state.proposal) { if (input === "y" && state.proposal.filename) { client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename }); dispatch({ type: "proposal-close" }); } else if (input === "n" || key.escape) { dispatch({ type: "proposal-close" }); } return; } if (input === "q") { client.close(); exit(); return; } if (input === "p") { const next = !state.paused; client.send(next ? COMMAND_TYPES.PAUSE : COMMAND_TYPES.RESUME, {}); dispatch({ type: "set-paused", paused: next }); } if (input === "s") { client.send(COMMAND_TYPES.STOP, {}); } if (input === "r") { client.send(COMMAND_TYPES.SNAPSHOT, {}); } if (input === "c") setMode("chat"); if (input === "a") setMode("ask-pi"); if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {}); }); function submit(value: string) { const text = value.trim(); setInputValue(""); const m = mode; setMode("idle"); if (!text) return; if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text }); else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text }); } const hotkeyHint = mode === "idle" ? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [y] proposals [q]uit" : mode === "chat" ? "chat → MC (Enter to send, Esc to cancel)" : "ask-pi → spawn pi -p (Enter to send)"; return ( {state.proposal ? ( dispatch({ type: "proposal-close" })} onApprove={() => { if (state.proposal?.filename) { client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename }); dispatch({ type: "proposal-close" }); } }} /> ) : null} {mode === "idle" ? ( {hotkeyHint} ) : ( <> {mode === "chat" ? "chat> " : "pi> "} )} ); } render();