Files
pepa-pi-bot/tui/tui.tsx
T
f5361ad9a4 feat(runtime): autonomous reflex — chop wood + wander + proactive movement (#8)
Closes the "bot stands on a tree doing nothing" problem reported live
when the operator launched the TUI after PR #6 landed. The reactive
chain (operator > defend > eat > sleep > idle) was passive by design:
day-time, full HP and food, no hostile within 4 m ⇒ every reflex
returned noop. The bot perched in dark-oak canopy and never moved.

Changes

runtime/actions.js:
  - chopNearestTree: find any *_log within 32 blocks, equip best axe
    (falls back to fists), path to the block, dig. Per-bot 5-min
    blacklist of unreachable log positions so we don't grind on the
    same impossible target.
  - wander: pick a random offset 6-16 blocks away and path there.
  - setMovementsForGather / setMovementsForTravel: every action that
    uses pathfinder now sets its own Movements profile (canDig=true)
    instead of inheriting whatever the previous caller left. The old
    behaviour caused chop to inherit flee's canDig=false and get stuck
    in the canopy.
  - fleeFrom now uses canDig=true too — the user observed the bot
    permanently stuck on a leaf block because escape required digging.

runtime/reflex.js:
  - new autonomousReflex between sleep and idle. Cooldown 10s. Picks
    chop when log count < 16, else wander. When chop reports "no
    reachable log within 32 blocks" we switch to wander for 60s so we
    don't re-fire chop against the same impossible position.
  - defendReflex tightened: only flee when closest is ≤8m (or ≤12m
    on low HP). Avoids the "82 distant hostiles ⇒ constant flee
    loop" pathology observed at this spawn.
  - flee cooldown: same mob name within 60s ⇒ noop, so we yield to
    other reflexes if flee keeps timing out.
  - sleepReflex retry cooldown raised 30s → 5min. Sleeping fails
    permanently if no bed is around; the short retry blocked
    autonomous behaviour every tick.
  - ctx.lastReflex now records {name, label, ts} after each
    dispatched/completed reflex so the TUI can show what the bot
    just decided.

runtime/bot.js:
  - per-tick snapshot adds lastReflex and busy fields for the TUI.
  - maybeReplyToPlayer: light canned greetings (yo/hey/hi/привет)
    to non-operators when they address the bot. 30s cooldown so we
    don't spam.

tui/tui.tsx:
  - status bar shows either "▸ busy: <label>" while an action is
    in flight, or "last reflex: <name> (<label>) Ns ago" when idle.
    Gives an at-a-glance answer to "what is the bot doing right now?"

Smoke-tested live (play.xmatic.team, 2026-05-25T13:30-13:39):
  - bot did dispatch chop tree (oak_log at 617,82,95)
  - real bug surfaced and 3-fail rule filed a proposal automatically
  - after the runtime fix, chop returns "no reachable log" gracefully
  - bot switched to wander on the next tick
  - position moved from (623.31, 85, 95.12) to (623.41, 86.02, 96.7)
    — the first observable movement in this dark-oak spawn

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:40:36 +03:00

376 lines
12 KiB
TypeScript

/**
* 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<string, any>;
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;
}
}
function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) {
const tone = snapshot.connected ? "green" : "red";
return (
<Box borderStyle="round" borderColor={tone} flexDirection="column" paddingX={1}>
<Text>
<Text color={tone} bold>
{snapshot.connected ? "● MC online" : "○ MC offline"}
</Text>
{" "}
<Text color={connectedToBot ? "green" : "red"}>{connectedToBot ? "IPC ok" : "IPC down"}</Text>
{" "}
{paused ? <Text color="yellow"> reflex paused</Text> : <Text color="green"> reflex live</Text>}
</Text>
<Text>
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 ? <Text color="magenta" bold>{` [proposals ${snapshot.pendingProposals}, press y]`}</Text> : null}
</Text>
<Text>
{snapshot.busy ? (
<Text color="cyan"> busy: {snapshot.busy.label}</Text>
) : snapshot.lastReflex ? (
<Text dimColor>
last reflex: {snapshot.lastReflex.name}
{snapshot.lastReflex.label ? ` (${snapshot.lastReflex.label})` : ""}{" "}
{snapshot.lastReflex.ts ? formatAge(snapshot.lastReflex.ts) : ""}
</Text>
) : (
<Text dimColor>no reflex action yet</Text>
)}
</Text>
</Box>
);
}
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 (
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor="gray">
<Text>No pending proposals. ([Esc] to close)</Text>
</Box>
);
}
const lines = (proposal.body ?? "").split("\n").slice(0, 30);
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor="magenta">
<Text bold color="magenta">
proposal: {proposal.filename} (total pending: {proposal.total})
</Text>
{lines.map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Text dimColor>[y]es approve [n]o close uses npm run propose:apply afterwards</Text>
</Box>
);
}
function EventLog({ logs }: { logs: LogEntry[] }) {
const last = logs.slice(-14);
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} width="60%">
<Text bold underline>
events
</Text>
{last.map((l, i) => (
<Text key={i} color={l.level === "warn" ? "yellow" : l.level === "error" ? "red" : "white"}>
{l.ts.slice(11, 19)} [{l.source}] {l.text}
</Text>
))}
</Box>
);
}
function ChatPanel({ chat }: { chat: ChatEntry[] }) {
const last = chat.slice(-14);
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} width="40%">
<Text bold underline>
MC chat
</Text>
{last.map((c, i) => (
<Text key={i} color={c.kind === "system" ? "gray" : "cyan"}>
{c.ts?.slice(11, 19) ?? ""} {c.from}: {c.text}
</Text>
))}
</Box>
);
}
function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean }) {
if (!piStream && !piRunning) return null;
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor={piRunning ? "magenta" : "gray"}>
<Text bold underline>
pi (escalation) {piRunning ? "● running" : "○ idle"}
</Text>
<Text>{piStream || "(no output yet)"}</Text>
</Box>
);
}
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<Mode>("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 (
<Box flexDirection="column">
<StatusBar snapshot={state.snapshot} paused={state.paused} connectedToBot={state.connectedToBot} />
<Box flexDirection="row">
<EventLog logs={state.logs} />
<ChatPanel chat={state.chat} />
</Box>
<PiPanel piStream={state.piStream} piRunning={state.piRunning} />
{state.proposal ? (
<ProposalPanel
proposal={state.proposal}
onClose={() => dispatch({ type: "proposal-close" })}
onApprove={() => {
if (state.proposal?.filename) {
client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename });
dispatch({ type: "proposal-close" });
}
}}
/>
) : null}
<Box borderStyle="single" paddingX={1}>
{mode === "idle" ? (
<Text dimColor>{hotkeyHint}</Text>
) : (
<>
<Text bold color={mode === "chat" ? "cyan" : "magenta"}>
{mode === "chat" ? "chat> " : "pi> "}
</Text>
<TextInput
value={inputValue}
onChange={setInputValue}
onSubmit={submit}
/>
</>
)}
</Box>
</Box>
);
}
render(<App />);