From effe9eebbe9469cb3912c7a32d8e1298fa425378 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 20:49:35 +0300 Subject: [PATCH] feat(tui): fullscreen monitor-only TUI (opencode-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old tui/tui.tsx hotkey-heavy dashboard with a read-only observability screen. Operator actions live in scripts/* now — TUI is for watching, not driving. Layout (top to bottom, all auto-resizing to terminal): 1. Header — MC/IPC status, pos, HP, food, day/night, hostiles 2. Storyline — current step + 11-step quest map (✓/→/○) 3. Activity — last N skill dispatches (colour by outcome) 4. MC Chat — last N chat lines (cyan for bot, yellow for players) 5. Advisor — last N LLM recommendations (trigger + outcome + tokens) 6. Improvements — open requests from knowledge.improvement_requests 7. Footer — 24h token usage + cost in ₽ + q-to-quit Data sources: - IPC sock: snapshot frames, log frames, chat frames (push) - SQLite knowledge.db: advisor_recommendations + improvement_requests polled every 5s (pull) Token cost displayed live using TIMEWEB_PRICE_IN_RUB_PER_M / TIMEWEB_PRICE_OUT_RUB_PER_M env vars (defaults: 101 / 608 for gpt-5.4-mini). Switches: - npm run tui → new monitor (this file) - npm run tui:legacy → old action-driven tui/tui.tsx (kept for now) Implementation notes: - Uses ink + alternate-screen-buffer ANSI for proper "opencode-feel" fullscreen behaviour; restores prior terminal contents on quit. - Skips alt-screen and useInput when stdin/stdout isn't a TTY (smoke tests, piped output) — both gracefully degrade. - Stable React keys via per-event uid counter, avoids reconciler duplicate-key warnings as logs/chat/dispatches stream in. - Resize handled via 1s stdout-dimension poll, NOT direct 'resize' listener (which conflicts with ink's own listener and triggers MaxListenersExceededWarning). Co-Authored-By: Claude Opus 4.7 --- package.json | 3 +- tui/monitor.tsx | 518 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 tui/monitor.tsx diff --git a/package.json b/package.json index d5e48d1..f1f46c5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "agent:resume": "pi -c", "bot": "node runtime/supervisor.js", "bot:bare": "node runtime/bot.js", - "tui": "tsx tui/tui.tsx", + "tui": "tsx tui/monitor.tsx", + "tui:legacy": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" diff --git a/tui/monitor.tsx b/tui/monitor.tsx new file mode 100644 index 0000000..f657bc0 --- /dev/null +++ b/tui/monitor.tsx @@ -0,0 +1,518 @@ +/** + * pepa monitor — fullscreen, read-only TUI. + * + * Replaces the old action-heavy tui/tui.tsx with a pure observability + * dashboard inspired by opencode's full-screen layout. No hotkeys to + * dispatch skills / send chat / approve proposals — operator does that + * via scripts/* or by writing to the IPC sock directly. This screen + * just shows what the bot is doing, in colour. + * + * Panels (top → bottom): + * 1. Header — connection / position / hp / food / time + * 2. Storyline — current step + the 11-step quest map + * 3. Activity — last N dispatches (colour-coded by outcome) + * + MC chat — last N lines + * 4. Advisor — last 6 LLM recommendations (trigger / outcome / tokens) + * + Improvements — open queue from improvement_requests + * 5. Footer — token usage today, advisor stats, q to quit + * + * Live data sources: + * - IPC sock (snapshot frames, log frames, chat frames) + * - SQLite (knowledge.db) polled every 5s for advisor + improvements + */ + +import React, { useEffect, useReducer, useState } from "react"; +import { render, Box, Text, useApp, useInput, useStdout } from "ink"; +import { createIpcClient } from "./ipc-client.js"; +import { EVENT_TYPES } from "../runtime/ipc-protocol.js"; +import { stateDir } from "../runtime/config.js"; +import { initKnowledge, isAvailable as knowledgeReady, recentRecommendations, listImprovements, recommendationStats } from "../runtime/knowledge/index.js"; + +// --- types ------------------------------------------------------------------ + +type LogEntry = { ts: string; level: string; source: string; text: string }; +type ChatEntry = { uid: number; ts: string; from: string; text: string; kind: string }; +type Snapshot = Record; +type Dispatch = { uid: number; ts: number; kind: "start" | "end"; label: string; ok?: boolean; code?: string; detail?: string }; + +let _uidSeq = 0; +function nextUid() { _uidSeq += 1; return _uidSeq; } + +type State = { + connectedIpc: boolean; + snapshot: Snapshot; + logs: LogEntry[]; + chat: ChatEntry[]; + dispatches: Dispatch[]; + startedAt: number; +}; + +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 }; + +const MAX_LOGS = 200; +const MAX_CHAT = 50; +const MAX_DISPATCHES = 30; + +// --- reducer ---------------------------------------------------------------- + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "ipc-connected": + return { ...state, connectedIpc: true }; + case "ipc-disconnected": + return { ...state, connectedIpc: false }; + case "snapshot": + return { ...state, snapshot: action.payload }; + case "log": { + const logs = [...state.logs, action.payload].slice(-MAX_LOGS); + // also derive a dispatches view: lines like "→ label" or "← label ok/fail (...)" + const dispatches = extractDispatch(state.dispatches, action.payload); + return { ...state, logs, dispatches }; + } + case "chat": { + const chat = [...state.chat, { uid: nextUid(), ts: action.ts, ...action.payload }].slice(-MAX_CHAT); + return { ...state, chat }; + } + default: + return state; + } +} + +function extractDispatch(prev: Dispatch[], log: LogEntry): Dispatch[] { + if (log.source !== "dispatch") return prev; + const ts = Date.parse(log.ts) || Date.now(); + // "→ label" — skill starting + const startMatch = /^→\s+(\S+)/.exec(log.text); + if (startMatch) { + return [...prev, { uid: nextUid(), ts, kind: "start", label: startMatch[1] }].slice(-MAX_DISPATCHES); + } + // "← label ok/fail (...)" — skill ending + const endMatch = /^←\s+(\S+)\s+(ok|fail)(?:\s+\((.*)\))?/.exec(log.text); + if (endMatch) { + const [, label, outcome, detail] = endMatch; + return [...prev, { uid: nextUid(), ts, kind: "end", label, ok: outcome === "ok", detail }].slice(-MAX_DISPATCHES); + } + return prev; +} + +// --- helpers ---------------------------------------------------------------- + +function formatAge(ts: number) { + const dt = Math.max(0, Date.now() - ts); + if (dt < 60_000) return `${Math.floor(dt / 1000)}s`; + if (dt < 3600_000) return `${Math.floor(dt / 60_000)}m`; + const h = Math.floor(dt / 3600_000); + const m = Math.floor((dt % 3600_000) / 60_000); + return `${h}h${m}m`; +} + +function formatDuration(ms: number) { + const s = Math.floor(ms / 1000) % 60; + const m = Math.floor(ms / 60_000) % 60; + const h = Math.floor(ms / 3600_000); + if (h > 0) return `${h}h${m.toString().padStart(2, "0")}m`; + if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`; + return `${s}s`; +} + +function shortTime(ts: number) { + const d = new Date(ts); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`; +} + +function hpColor(hp: number | undefined) { + if (hp == null) return "gray"; + if (hp <= 5) return "red"; + if (hp <= 12) return "yellow"; + return "green"; +} +function foodColor(food: number | undefined) { + if (food == null) return "gray"; + if (food <= 4) return "red"; + if (food <= 10) return "yellow"; + return "green"; +} + +// --- components ------------------------------------------------------------- + +function Header({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) { + const pos = snapshot.position + ? `(${Math.round(snapshot.position.x)}, ${Math.round(snapshot.position.y)}, ${Math.round(snapshot.position.z)})` + : "?"; + const hp = snapshot.health; + const food = snapshot.food; + const day = snapshot.isDay ? "☀ day" : "🌙 night"; + const session = formatDuration(Date.now() - startedAt); + const mcOnline = snapshot.connected; + return ( + + + pepa-monitor + · + {mcOnline ? "● MC" : "○ MC"} + · + {connectedIpc ? "● IPC" : "○ IPC"} + · + session {session} + · + user {snapshot.username ?? "?"} + + + pos {pos} + + HP + {hp ?? "?"}/20 + + food + {food ?? "?"}/20 + + {day} + hostiles + 0 ? "red" : "gray"}>{snapshot.hostileCount ?? 0} + {snapshot.closestHostile ? closest {snapshot.closestHostile.name}@{snapshot.closestHostile.distance}b : null} + + + ); +} + +function StorylinePanel({ snapshot, width }: { snapshot: Snapshot; width: number }) { + const story = snapshot.storyStep; + if (!story) { + return ( + + storyline not available (bot offline or storyline disabled) + + ); + } + const STEPS = [ + "orient_self", "first_wood", "crafting_basics", "first_tools", "first_food", + "shelter_minimal", "stone_tier", "food_security", "iron_age", "settle_base", "village_grow", + ]; + const TITLES: Record = { + orient_self: "Понять где я", + first_wood: "Собрать 8 поленьев", + crafting_basics: "Сделать верстак и палки", + first_tools: "Деревянные орудия", + first_food: "Найти первую еду", + shelter_minimal: "Простой шелтер с кроватью", + stone_tier: "Каменные орудия", + food_security: "Запас еды на 16+", + iron_age: "Железо и печь", + settle_base: "Постоянная база", + village_grow: "Развивать деревню", + }; + const idx = story.index ?? 0; + const cur = story.step; + const want = story.suggestion?.skillId; + const emergency = story.emergency; + return ( + + + storyline + step + {idx + 1}/11 + + {cur?.id} + {cur?.title} + {emergency ? [EMERGENCY PAUSE] : null} + + {want ? ( + + wants: + {want} + + ) : null} + + {STEPS.map((id, i) => { + const done = i < idx; + const current = i === idx; + const mark = done ? "✓" : current ? "→" : "○"; + const color = done ? "green" : current ? "yellow" : "gray"; + return ( + + {` ${mark} ${(i + 1).toString().padStart(2)}. ${id.padEnd(18)} ${TITLES[id] ?? ""}`} + + ); + })} + + + ); +} + +function ActivityPanel({ dispatches, width, height }: { dispatches: Dispatch[]; width: number; height: number }) { + const visible = dispatches.slice(-height); + return ( + + Activity (last {visible.length}) + {visible.map((d) => { + if (d.kind === "start") { + return ( + + {shortTime(d.ts)} → {d.label} + + ); + } + const color = d.ok ? "green" : "red"; + const tail = d.detail ? ` (${String(d.detail).slice(0, 30)})` : ""; + return ( + + {shortTime(d.ts)} + ← {d.label} {d.ok ? "ok" : "fail"} + {tail} + + ); + })} + {visible.length === 0 ? (waiting for activity…) : null} + + ); +} + +function ChatPanel({ chat, width, height }: { chat: ChatEntry[]; width: number; height: number }) { + const visible = chat.slice(-height); + return ( + + MC Chat (last {visible.length}) + {visible.map((c) => ( + + {c.ts?.slice(11, 16) ?? ""} + + {c.from}: + + {c.text.slice(0, width - 12)} + + ))} + {visible.length === 0 ? (no chat yet…) : null} + + ); +} + +function AdvisorPanel({ recs, width, height }: { recs: any[]; width: number; height: number }) { + const visible = recs.slice(0, height); + return ( + + Advisor (last {visible.length}) + {visible.map((r) => { + const ok = r.outcome_ok; + const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗"; + const outcomeColor = ok == null ? "gray" : ok ? "green" : "red"; + const tail = `${r.tokens_in ?? "?"}/${r.tokens_out ?? "?"}t ${r.latency_ms ?? "?"}ms`; + return ( + + + {outcomeMark} + {r.trigger_reason} + + {r.recommended_skill ?? r.action} + + {tail}{r.rationale ? ` · ${String(r.rationale).slice(0, width - 14)}` : ""} + + ); + })} + {visible.length === 0 ? (no advisor calls yet) : null} + + ); +} + +function ImprovementsPanel({ items, width, height }: { items: any[]; width: number; height: number }) { + const visible = items.slice(0, height); + return ( + + Improvements (open: {items.length}) + {visible.map((r) => ( + + + #{r.id} + P{r.priority} + ×{r.votes} + {String(r.title).slice(0, width - 14)} + + {r.description ? ( + {String(r.description).slice(0, width - 8)} + ) : null} + + ))} + {visible.length === 0 ? (no improvement requests yet) : null} + + ); +} + +function Footer({ stats, width }: { stats: any[]; width: number }) { + const total = stats.reduce( + (acc, s) => ({ + calls: acc.calls + (s.total ?? 0), + succ: acc.succ + (s.succeeded ?? 0), + fail: acc.fail + (s.failed ?? 0), + in: acc.in + (s.avg_in ?? 0) * (s.total ?? 0), + out: acc.out + (s.avg_out ?? 0) * (s.total ?? 0), + }), + { calls: 0, succ: 0, fail: 0, in: 0, out: 0 }, + ); + const priceInRub = Number(process.env.TIMEWEB_PRICE_IN_RUB_PER_M) || 101; + const priceOutRub = Number(process.env.TIMEWEB_PRICE_OUT_RUB_PER_M) || 608; + const costRub = (total.in * priceInRub + total.out * priceOutRub) / 1_000_000; + return ( + + + Last 24h: + {total.calls} + advisor calls ( + {total.succ} ok + / + {total.fail} fail + ) tokens + {Math.round(total.in / 1000)}K + in / + {Math.round(total.out / 1000)}K + out ≈ + {costRub.toFixed(2)} ₽ + + + q — quit (bot keeps running) + + + ); +} + +// --- main app --------------------------------------------------------------- + +function App() { + const { exit } = useApp(); + const { stdout } = useStdout(); + // Sample stdout dimensions periodically. Subscribing directly to + // stdout.on('resize') from a React hook conflicts with ink's own + // listener and produces "MaxListenersExceededWarning" / reconciler + // errors. A 1s poll is cheap and good enough — terminals don't + // resize often. + const [cols, setCols] = useState((stdout as any)?.columns ?? 120); + const [rows, setRows] = useState((stdout as any)?.rows ?? 30); + useEffect(() => { + const t = setInterval(() => { + setCols((stdout as any)?.columns ?? 120); + setRows((stdout as any)?.rows ?? 30); + }, 1000); + return () => clearInterval(t); + }, [stdout]); + + const [state, dispatch] = useReducer(reducer, { + connectedIpc: false, + snapshot: {}, + logs: [], + chat: [], + dispatches: [], + startedAt: Date.now(), + }); + + const [client] = useState(() => createIpcClient()); + const [knowledgeOk, setKnowledgeOk] = useState(false); + const [recs, setRecs] = useState([]); + const [improvements, setImprovements] = useState([]); + const [stats, setStats] = useState([]); + + // IPC + 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.HELLO: + if (frame.payload?.snapshot) dispatch({ type: "snapshot", payload: frame.payload.snapshot }); + if (frame.payload?.recentLogs) { + for (const lg of frame.payload.recentLogs) dispatch({ type: "log", payload: lg }); + } + 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]); + + // Knowledge DB init + polling + useEffect(() => { + let mounted = true; + (async () => { + try { + await initKnowledge({ stateDir }); + if (!mounted) return; + setKnowledgeOk(knowledgeReady()); + } catch {} + })(); + const poll = () => { + if (!knowledgeReady()) return; + try { + setRecs(recentRecommendations({ limit: 12 })); + setImprovements(listImprovements({ status: "open", limit: 12 })); + setStats(recommendationStats({ sinceHours: 24 })); + } catch {} + }; + poll(); + const t = setInterval(poll, 5000); + return () => { mounted = false; clearInterval(t); }; + }, [knowledgeOk]); + + // useInput requires TTY raw mode; skip it when stdin isn't a TTY + // (e.g. when piped during a smoke test). Real `npm run tui` is always TTY. + const isTty = !!process.stdin.isTTY; + if (isTty) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useInput((input, key) => { + if (input === "q" || (key.ctrl && input === "c") || key.escape) { + client.close(); + exit(); + } + }); + } + + // Layout math: full-width header & storyline, then split 50/50. + const totalWidth = Math.max(80, cols); + const halfWidth = Math.floor(totalWidth / 2); + const totalRows = Math.max(24, rows); + // rough allocation: header 4, storyline ~14, footer 4. Rest split for middle panels. + const middleRows = Math.max(8, Math.floor((totalRows - 4 - 14 - 4) / 2)); + + return ( + +
+ + + + + + + + + +