/** * 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; }; 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[] } }; 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 || [], }; default: return state; } } function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) { const tone = snapshot.connected ? "green" : "red"; return ( {snapshot.connected ? "● MC online" : "○ MC offline"} {" "} {connectedToBot ? "IPC ok" : "IPC down"} {" "} {paused ? ⏸ reflex paused : ▶ reflex live} 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` : ""} ); } 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, }); 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; } }; (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 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"); }); 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 [q]uit" : mode === "chat" ? "chat → MC (Enter to send, Esc to cancel)" : "ask-pi → spawn pi -p (Enter to send)"; return ( {mode === "idle" ? ( {hotkeyHint} ) : ( <> {mode === "chat" ? "chat> " : "pi> "} )} ); } render();