Console TUI plan
Decided by three judges (votes/judge-1.md, votes/judge-2.md, votes/judge-3.md) on the ballot.md options, all three voting Q1=A, Q2=2a, Q3=3b, Q4=4a, Q5=5a. The research notes in this directory are the evidence. This file is the contract the implementation slices follow.
Decision
- Host-first topology. The
prismarineentry point decides its role at startup:- TUI mode when stdin and stdout are TTYs,
TERMis notdumb,CIandJSP_NO_TUIare unset,--no-tuiwas not passed, andawait import('node:ffi')succeeds. The process becomes the host: it spawnsprocess.execPathwithprocess.execArgv, the same script, the user's flags, and--role server, usingstdio: ['ignore', 'pipe', 'pipe', 'ipc']. It lazily imports@jsprismarine/consoleand owns the terminal alone. - Plain mode otherwise: the server runs in-process with the line console (no raw mode) and plain stdout lines, so containers, panels, Node 22, and
docker logsare unchanged. When the TUI gate fails for a reason the user would not expect (Node too old), print one line that names the reason before starting. --role server(hidden flag): run the serverheadless: trueand attach the remote console over the IPC channel.
- TUI mode when stdin and stdout are TTYs,
--watchmoves the file watchers and bedrock-data regeneration frompackages/server/scripts/dev.mtsinto the host;dev.mtsbecomes a thin launcher. Supervisor state (watching, change detected, generating, restart queued, restarting, exited, waiting) renders in the TUI as a status segment and toasts, never as log lines.- Plain
@opentui/core0.6.8, pinned exactly, plus@opentui/keymapat the same version if it runs on Node without the React or Solid adapters. No React, no Solid, no JSX. - New package
@jsprismarine/console-protocolholds the message types and link adapters. Neither it nor@jsprismarine/prismarinedepends on OpenTUI. - Log pane is a windowed view over a JS ring buffer (3b), never one renderable per record.
packages/prismarine/src/Console.tsbecomes a line console: reads lines from stdin, writes lines to stdout, no raw mode, no prompt redraw, no completer.- Ctrl+C in the TUI clears the input line; a second press on an empty line (within two seconds, shown as a status hint) stops the server; a press while the server is
stoppingforce kills.
Protocol (packages/console-protocol)
export const PROTOCOL_VERSION = 1;
// LogLevel comes from @jsprismarine/logger.
export interface LogEntry { seq: number; level: LogLevel; namespace: string; at: number; message: string; formatted: string }
export type ServerMessage =
| { type: 'hello'; v: 1; serverVersion: string; pid: number }
| ({ type: 'log'; v: 1 } & LogEntry)
| { type: 'log.dropped'; v: 1; count: number }
| { type: 'status'; v: 1; tps: number; tickMs: number; players: number; maxPlayers: number; rssBytes: number; uptimeMs: number }
| { type: 'lifecycle'; v: 1; state: 'starting' | 'running' | 'stopping' }
| { type: 'command.result'; v: 1; id: string; ok: boolean; error?: string }
| { type: 'complete.result'; v: 1; id: string; completions: readonly string[]; replace: string };
export type ConsoleMessage =
| { type: 'command'; v: 1; id: string; line: string }
| { type: 'complete'; v: 1; id: string; line: string; cursor: number }
| { type: 'shutdown'; v: 1 }
| { type: 'subscribe'; v: 1; level: LogLevel };
export type SupervisorEvent =
| { type: 'supervisor.state'; state: 'starting' | 'running' | 'stopping' | 'stopped' | 'crashed' | 'restarting' | 'waiting'; code?: number | null; signal?: string | null; generation: number }
| { type: 'supervisor.watching'; root: string }
| { type: 'supervisor.changed'; path: string }
| { type: 'supervisor.generating'; ok?: boolean }
| { type: 'supervisor.output'; stream: 'stdout' | 'stderr'; text: string };
export interface MessageLink<In, Out> {
send(message: Out): boolean; // false means the channel asked for a pause; keep sending is allowed but the sender should cap its own queue
onMessage(listener: (message: In) => void): () => void;
onClose(listener: () => void): () => void;
close(): void;
}Adapters: childProcessLink() for the server side (process.send, process.on('message'), 'disconnect'), subprocessLink(child) for the host side, and memoryLinkPair() for tests. Type guards isServerMessage and isConsoleMessage validate incoming JSON shapes.
stopped and crashed are derived by the host from the child's exit event (code and signal), not from a final IPC message, because shutdown() exits before queued sends drain.
Server side (packages/prismarine/src/console/)
RemoteConsole (a Service): takes a MessageLink<ConsoleMessage, ServerMessage>. It adds a LogTransport to the logger that forwards records as log messages with a monotonically increasing seq, kept in a bounded ring (5 000 records) while send returns false, and emits log.dropped with the count that fell off. It runs command through CommandManager.dispatchCommand with the server's ConsoleSender and replies command.result; answers complete with completeCommand from Console.ts; sends status at 1 Hz (coalesced, only while something changed or every 5 s); sends lifecycle; and on shutdown or link close runs the normal graceful server.shutdown(). The server never writes log lines to its own stdout in this mode. process.exit in shutdown must wait for the last send callback.
Host (packages/server/src/host/)
ServerChild: spawn, restart, SIGTERM then SIGKILL after 10 s,shutdownmessage first (works on Windows), exit code propagation, pipes child stdout and stderr assupervisor.output.Watcher: the watch and bedrock-data generation logic fromdev.mts, emittingSupervisorEvents instead of printing.Supervisor: state machine combining both; the single source ofSupervisorEvents.tuiGate.ts: the mode decision described above, with a reason string.- Entry:
packages/server/src/Server.tsdispatches on role and mode;Cli.tsgains--no-tui,--watch,--headless, and the hidden--role. - Terminal safety: the host restores the terminal on
exit,uncaughtException, and signals (leave alternate screen, mouse off, bracketed paste off, cursor on, raw mode off), and every renderer event handler is wrapped so a thrown error becomes a toast.
TUI (packages/console)
Component contract: each component is a factory function in its own file, create<Name>(ctx: ConsoleContext, props): Renderable where ConsoleContext = { renderer, theme, store, keymap }. Each has a sibling *.test.ts on createTestRenderer(). Pure logic (windowing, filters, history, completion selection, segment truncation) lives in plain modules with their own tests and no OpenTUI import. Nothing outside packages/console imports @opentui/core.
Directories, one owner each: src/theme/, src/log/ (store, window, line, view, separator, badge), src/input/ (bar, history, completer), src/status/ (bar, segments, toasts), src/help/ (overlay from the keymap catalog), src/app/ (layout, breakpoints, focus, wiring), src/host/ (the startConsole(options) entry the host calls). src/index.ts exports startConsole and the option types only, and is edited by the app slice alone.
startConsole({ server: MessageLink<ServerMessage, ConsoleMessage>, supervisor: { onEvent }, actions: { restart(), stop(), forceStop() }, mode: 'tui', historyFile?: string }): Promise<{ close(): Promise<void> }>.
Layout, degradation rules, shortcuts, and theme roles follow ux-design.md sections 1, 5, and 6. Styled text is built from level, namespace, and message (with § codes mapped to theme colors), not by parsing formatted.
Slices and order
Wave 1 (independent): protocol package; line console; TUI theme; TUI input bar and history; TUI status bar, segments, and toasts; SEA spike (ESM main, asset extraction, self spawn with 'ipc'), report only. Wave 2 (after protocol): remote console in prismarine; host modules and entry wiring; log store and log view. Wave 3 (after wave 2): app composition and startConsole; help overlay and keymap; dev launcher replacement and dev-smoke.exp retirement; host integration test with a fake child. Wave 4: docs (docs/guide/running.md, READMEs, Dockerfile JSP_NO_TUI), changesets, baseline cleanups, full lint and test.