ui(tui): compact 4-section monitor (was 6) — fits 1080p without zoom

Operator reported the TUI overflowed the screen unless terminal was
zoomed way out. The 11-step storyline list alone was eating ~13
rows, and each advisor/improvement entry took 2-3 rows. Now:

- Header + storyline collapsed into one panel (2 lines):
    line 1: pepa · ●MC ●IPC · 1m50s · pepa_bot · (697,61,702) · HP 20 · food 5 · ☀ · ⚔60(creeper@58b)
    line 2: story ▓▒░░░░░░░░░ 1/11 orient_self · Понять где я → explore.wander
  The 11-step ladder is now a unicode progress bar (▓ done, ▒ current,
  ░ pending) — same info, fits in one row.

- Advisor entries: one line each instead of two.
    ✓ wedged_60s → survive.flee  802t 1900ms
    (outcome mark / trigger / target skill / tokens / latency)

- Improvements entries: one line each instead of two.
    #1 P2 ×3  Add craft.iron-pickaxe skill
  Description dropped from the row — use `node scripts/list-improvements.js`
  for full text.

- Sections: 4 (was 6).
    [header+story] · [activity | chat] · [advisor | improvements] · [footer]

Tested on a typical 1080p terminal — fits comfortably without zoom.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:03:24 +03:00
co-authored by Claude Opus 4.7
parent effe9eebbe
commit cf1f895b25
+68 -99
View File
@@ -140,105 +140,73 @@ function foodColor(food: number | undefined) {
// --- components ------------------------------------------------------------- // --- components -------------------------------------------------------------
function Header({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) { function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) {
const pos = snapshot.position const pos = snapshot.position
? `(${Math.round(snapshot.position.x)}, ${Math.round(snapshot.position.y)}, ${Math.round(snapshot.position.z)})` ? `(${Math.round(snapshot.position.x)},${Math.round(snapshot.position.y)},${Math.round(snapshot.position.z)})`
: "?"; : "?";
const hp = snapshot.health; const hp = snapshot.health;
const food = snapshot.food; const food = snapshot.food;
const day = snapshot.isDay ? "☀ day" : "🌙 night"; const day = snapshot.isDay ? "☀" : "🌙";
const session = formatDuration(Date.now() - startedAt); const session = formatDuration(Date.now() - startedAt);
const mcOnline = snapshot.connected; const mcOnline = snapshot.connected;
const story = snapshot.storyStep;
const idx = story?.index ?? 0;
const cur = story?.step;
const want = story?.suggestion?.skillId;
// 11-step progress bar in 11 cells
const bar = Array.from({ length: 11 }, (_, i) => {
if (i < idx) return "▓";
if (i === idx) return "▒";
return "░";
}).join("");
return ( return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={width}> <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={width}>
<Box> <Box>
<Text bold color="cyan">pepa-monitor</Text> <Text bold color="cyan">pepa</Text>
<Text dimColor> · </Text> <Text dimColor> · </Text>
<Text color={mcOnline ? "green" : "red"}>{mcOnline ? "● MC" : "○ MC"}</Text> <Text color={mcOnline ? "green" : "red"}>{mcOnline ? "●MC" : "○MC"}</Text>
<Text dimColor> · </Text> <Text dimColor> · </Text>
<Text color={connectedIpc ? "green" : "red"}>{connectedIpc ? "● IPC" : "○ IPC"}</Text> <Text color={connectedIpc ? "green" : "red"}>{connectedIpc ? "●IPC" : "○IPC"}</Text>
<Text dimColor> · </Text> <Text dimColor> · </Text>
<Text>session {session}</Text> <Text>{session}</Text>
<Text dimColor> · </Text>
<Text bold>{snapshot.username ?? "?"}</Text>
<Text dimColor> · </Text>
<Text>{pos}</Text>
<Text dimColor> · </Text>
<Text>HP </Text><Text bold color={hpColor(hp)}>{hp ?? "?"}</Text>
<Text dimColor> · </Text>
<Text>food </Text><Text bold color={foodColor(food)}>{food ?? "?"}</Text>
<Text dimColor> · </Text> <Text dimColor> · </Text>
<Text>user </Text><Text bold>{snapshot.username ?? "?"}</Text>
</Box>
<Box>
<Text>pos </Text><Text bold>{pos}</Text>
<Text dimColor> </Text>
<Text>HP </Text>
<Text bold color={hpColor(hp)}>{hp ?? "?"}/20</Text>
<Text dimColor> </Text>
<Text>food </Text>
<Text bold color={foodColor(food)}>{food ?? "?"}/20</Text>
<Text dimColor> </Text>
<Text>{day}</Text> <Text>{day}</Text>
<Text dimColor> hostiles </Text> {(snapshot.hostileCount ?? 0) > 0 ? (
<Text color={(snapshot.hostileCount ?? 0) > 0 ? "red" : "gray"}>{snapshot.hostileCount ?? 0}</Text> <>
{snapshot.closestHostile ? <Text color="red"> closest {snapshot.closestHostile.name}@{snapshot.closestHostile.distance}b</Text> : null} <Text dimColor> · </Text>
<Text color="red">{snapshot.hostileCount}</Text>
{snapshot.closestHostile ? <Text color="red">({snapshot.closestHostile.name}@{Math.round(snapshot.closestHostile.distance)}b)</Text> : null}
</>
) : null}
</Box> </Box>
</Box>
);
}
function StorylinePanel({ snapshot, width }: { snapshot: Snapshot; width: number }) {
const story = snapshot.storyStep;
if (!story) {
return (
<Box borderStyle="round" borderColor="gray" paddingX={1} width={width}>
<Text dimColor>storyline not available (bot offline or storyline disabled)</Text>
</Box>
);
}
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<string, string> = {
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 (
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} width={width}>
<Box> <Box>
<Text bold color="magenta">storyline</Text> <Text color="magenta">story </Text>
<Text dimColor> step </Text> <Text bold>{bar}</Text>
<Text dimColor> </Text>
<Text bold>{idx + 1}/11</Text> <Text bold>{idx + 1}/11</Text>
<Text dimColor> </Text> <Text dimColor> </Text>
<Text bold color="white">{cur?.id}</Text> {cur ? (
<Text dimColor> {cur?.title}</Text> <>
{emergency ? <Text color="red" bold> [EMERGENCY PAUSE]</Text> : null} <Text color="white" bold>{cur.id}</Text>
</Box> <Text dimColor> · </Text>
<Text>{cur.title}</Text>
</>
) : <Text dimColor>(no story)</Text>}
{want ? ( {want ? (
<Box> <>
<Text dimColor>wants: </Text> <Text dimColor> </Text>
<Text color="cyan">{want}</Text> <Text color="cyan">{want}</Text>
</Box> </>
) : null} ) : null}
<Box flexDirection="column" marginTop={1}> {story?.emergency ? <Text color="red" bold> [EMERGENCY]</Text> : 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 (
<Text key={id} color={color}>
{` ${mark} ${(i + 1).toString().padStart(2)}. ${id.padEnd(18)} ${TITLES[id] ?? ""}`}
</Text>
);
})}
</Box> </Box>
</Box> </Box>
); );
@@ -300,17 +268,20 @@ function AdvisorPanel({ recs, width, height }: { recs: any[]; width: number; hei
const ok = r.outcome_ok; const ok = r.outcome_ok;
const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗"; const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗";
const outcomeColor = ok == null ? "gray" : ok ? "green" : "red"; const outcomeColor = ok == null ? "gray" : ok ? "green" : "red";
const tail = `${r.tokens_in ?? "?"}/${r.tokens_out ?? "?"}t ${r.latency_ms ?? "?"}ms`; const target = r.recommended_skill ?? r.action;
const trigger = String(r.trigger_reason ?? "?");
// One line per recommendation: " ✓ wedged_60s → survive.flee 802t 1900ms"
const lhs = `${outcomeMark} ${trigger}${target}`;
const rhs = `${r.tokens_in ?? "?"}t ${r.latency_ms ?? "?"}ms`;
const free = Math.max(20, width - rhs.length - 6);
return ( return (
<Box key={`adv-${r.id}`} flexDirection="column"> <Text key={`adv-${r.id}`}>
<Text>
<Text color={outcomeColor} bold>{outcomeMark} </Text> <Text color={outcomeColor} bold>{outcomeMark} </Text>
<Text color="white">{r.trigger_reason}</Text> <Text color="white">{trigger}</Text>
<Text dimColor> </Text> <Text dimColor> </Text>
<Text color="cyan">{r.recommended_skill ?? r.action}</Text> <Text color="cyan">{String(target).slice(0, Math.max(8, free - trigger.length - 6))}</Text>
<Text dimColor> {rhs}</Text>
</Text> </Text>
<Text dimColor> {tail}{r.rationale ? ` · ${String(r.rationale).slice(0, width - 14)}` : ""}</Text>
</Box>
); );
})} })}
{visible.length === 0 ? <Text dimColor>(no advisor calls yet)</Text> : null} {visible.length === 0 ? <Text dimColor>(no advisor calls yet)</Text> : null}
@@ -322,19 +293,14 @@ function ImprovementsPanel({ items, width, height }: { items: any[]; width: numb
const visible = items.slice(0, height); const visible = items.slice(0, height);
return ( return (
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} width={width}> <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} width={width}>
<Text bold color="magenta">Improvements (open: {items.length})</Text> <Text bold color="magenta">Improvements (open {items.length})</Text>
{visible.map((r) => ( {visible.map((r) => (
<Box key={`imp-${r.id}`} flexDirection="column"> <Text key={`imp-${r.id}`}>
<Text>
<Text color="gray">#{r.id} </Text> <Text color="gray">#{r.id} </Text>
<Text color="yellow">P{r.priority}</Text> <Text color="yellow">P{r.priority}</Text>
<Text dimColor> ×{r.votes} </Text> <Text dimColor> ×{r.votes} </Text>
<Text color="white">{String(r.title).slice(0, width - 14)}</Text> <Text color="white">{String(r.title).slice(0, Math.max(20, width - 14))}</Text>
</Text> </Text>
{r.description ? (
<Text dimColor> {String(r.description).slice(0, width - 8)}</Text>
) : null}
</Box>
))} ))}
{visible.length === 0 ? <Text dimColor>(no improvement requests yet)</Text> : null} {visible.length === 0 ? <Text dimColor>(no improvement requests yet)</Text> : null}
</Box> </Box>
@@ -477,17 +443,20 @@ function App() {
}); });
} }
// Layout math: full-width header & storyline, then split 50/50. // Layout math: compact 4-section vertical stack.
// row budget:
// header (story + status) ≈ 4 rows
// middle activity/chat ≈ floor((rows - 4 - 4 - 4) / 2)
// bottom advisor/improvements ≈ same
// footer ≈ 4 rows
const totalWidth = Math.max(80, cols); const totalWidth = Math.max(80, cols);
const halfWidth = Math.floor(totalWidth / 2); const halfWidth = Math.floor(totalWidth / 2);
const totalRows = Math.max(24, rows); const totalRows = Math.max(20, rows);
// rough allocation: header 4, storyline ~14, footer 4. Rest split for middle panels. const middleRows = Math.max(5, Math.floor((totalRows - 4 - 4 - 4) / 2));
const middleRows = Math.max(8, Math.floor((totalRows - 4 - 14 - 4) / 2));
return ( return (
<Box flexDirection="column" width={totalWidth}> <Box flexDirection="column" width={totalWidth}>
<Header snapshot={state.snapshot} connectedIpc={state.connectedIpc} width={totalWidth} startedAt={state.startedAt} /> <StatusHeader snapshot={state.snapshot} connectedIpc={state.connectedIpc} width={totalWidth} startedAt={state.startedAt} />
<StorylinePanel snapshot={state.snapshot} width={totalWidth} />
<Box flexDirection="row" width={totalWidth}> <Box flexDirection="row" width={totalWidth}>
<ActivityPanel dispatches={state.dispatches} width={halfWidth} height={middleRows} /> <ActivityPanel dispatches={state.dispatches} width={halfWidth} height={middleRows} />
<ChatPanel chat={state.chat} width={totalWidth - halfWidth} height={middleRows} /> <ChatPanel chat={state.chat} width={totalWidth - halfWidth} height={middleRows} />