feat(editor): F4.6b the command line on a computer's desktop

A new oracle, TerminalWindowRun, types into Java's own command line
window: the window writes each line after its prompt, takes a blank
line, cls, reset and exit itself, keeps the lines for the arrow keys,
and keeps its text, history and working directory over a stop and a
start.

The room now holds a command line window per computer for everyone
(TerminalWindow), kept over stops; the protocol carries its lines and
the one way to read them (terminalScreen); the core goes on in the
working directory of the run before (continueIn). The desktop opens
programs in Java's frame, and the command line works with keyboard and
finger: grey on black, the prompt hidden while a command runs, history,
chips for the commands, Ctrl+C. The room's failure is now tested in the
browser.

Fixed: the room handed the engine the wall clock with fractions of a
millisecond, and a ping hung after its first timeout. The engine and
the room count whole milliseconds, as Java's clock does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:22:49 +00:00
co-authored by Claude Opus 5.5
parent dfd5ec03ce
commit ac48e969f4
23 changed files with 1255 additions and 47 deletions
+34 -8
View File
@@ -7,13 +7,16 @@
// Time: the engine's clock follows the wall clock, a millisecond for a
// millisecond, as Java's threads do; the speed is Java's slider, the delay of
// a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3).
// The engine counts whole milliseconds, as Java's clock does; the wall
// clock's fractions are carried to the next tick, so that no time is lost.
// A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept,
// the simulation goes on from where it was rather than running the missed
// minutes in one go.
//
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
// the next start goes on at the time it stopped, so that the capture's times
// keep rising (plan §6.2).
// keep rising (plan §6.2). So do the command lines' windows, as Java's do
// (TerminalWindow).
import * as Y from 'yjs';
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
@@ -30,6 +33,7 @@ import {
type Topic,
type TraceFrame,
} from '@mdg-filius/protocol';
import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200;
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
@@ -71,6 +75,8 @@ export class SimulationRoom {
/** Per card, how many frames it held at the last tick. */
private framesSeen = new Map<Id, number>();
private lastActivity: string[] = [];
/** Each computer's command line window, kept over stops. */
private readonly terminalWindows = new Map<Id, TerminalWindow>();
constructor(
private readonly doc: Y.Doc,
@@ -162,6 +168,13 @@ export class SimulationRoom {
this.engine.setSimulationSpeed(this.speed);
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
for (const node of snapshot.nodes) {
const terminal = this.engine.terminal(node.id);
if (terminal === undefined) continue;
const window = this.terminalWindows.get(node.id);
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
else window.attach(terminal);
}
this.mode = 'simulation';
this.lastWall = this.options.now();
this.broadcast(this.state());
@@ -181,8 +194,10 @@ export class SimulationRoom {
tick(): void {
if (this.mode !== 'simulation') return;
const now = this.options.now();
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.max(0, now - (this.lastWall ?? now)));
this.lastWall = now;
const behind = Math.max(0, now - (this.lastWall ?? now));
// Whole milliseconds, as the engine counts them; the wall clock's fraction is kept for the next tick.
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.floor(behind));
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
this.flush([...this.clients.values()], false);
}
@@ -212,15 +227,16 @@ export class SimulationRoom {
if (this.mode !== 'simulation') return 'notRunning';
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
if (input.app !== 'terminal') return 'unknownAction';
const terminal = this.engine.terminal(input.node);
if (terminal === undefined) return 'unknownApp';
// A window has a program while the simulation runs (checked above), from its first start on.
const window = this.terminalWindows.get(input.node);
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
switch (input.action) {
case 'enter':
if (typeof input.args.line !== 'string') return 'badArgs';
terminal.enter(input.args.line);
window.enter(input.args.line);
return undefined;
case 'interrupt':
terminal.interrupt();
window.interrupt();
return undefined;
default:
return 'unknownAction';
@@ -251,6 +267,7 @@ export class SimulationRoom {
/** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */
private flush(clients: readonly Client[], subscribing: boolean): void {
if (!subscribing) this.updateActivity();
for (const window of this.terminalWindows.values()) window.pull();
const rowsCache = new Map<Id, TraceFrame[]>();
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
@@ -281,8 +298,17 @@ export class SimulationRoom {
client.send(message({ kind: 'trace', portId, frames, reset }));
} else {
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
const program = this.program(node!, app as AppKind);
const sent = client.appSent.get(topic);
if (app === 'terminal') {
// The window's lines, kept over stops: a client goes on from those it has.
const lines = this.terminalWindows.get(node!)?.lines ?? [];
const from = sent ?? 0;
if (sent !== undefined && lines.length === from) continue;
client.appSent.set(topic, lines.length);
client.send(message({ kind: 'app', node: node!, app: 'terminal', from, lines: lines.slice(from) }));
continue;
}
const program = this.program(node!, app as AppKind);
if (program === undefined) {
// A window of a program that is not running: it starts empty with the next start.
if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] }));
+89
View File
@@ -0,0 +1,89 @@
// A computer's command line window in the room (plan §7.3): one for everyone
// who opens it, as Java has one per computer. It writes the program's lines
// into the window's lines after the window's own (the start screen, each
// entered line after its prompt, a blank line, cls, exit), in the order they
// happened. Like Java's window it outlives a stop and a start: its text, its
// history and the working directory stay (TerminalWindowRun); each start
// brings a new program, which goes on where the last one was.
import type { Terminal } from '@mdg-filius/core';
import type { TerminalLine } from '@mdg-filius/protocol';
/** StringUtils.isBlank: nothing but whitespace. */
const blank = (line: string) => line.trim() === '';
export class TerminalWindow {
private readonly linesValue: TerminalLine[] = [];
private terminal!: Terminal;
/** How many of the program's lines are in the window. */
private seen = 0;
private busy = false;
private prompt: string | undefined;
private directory = '/';
/** A window on the program of the first start. */
constructor(terminal: Terminal) {
this.attach(terminal);
}
get lines(): readonly TerminalLine[] {
return this.linesValue;
}
/** The program of a start: the first time the window shows its start screen, later it goes on in the working directory it had. */
attach(terminal: Terminal): void {
this.terminal = terminal;
this.seen = 0;
terminal.continueIn(this.directory);
if (this.linesValue.length === 0) this.linesValue.push({ kind: 'text', text: terminal.startScreen });
if (this.busy) {
// A command cut off by the stop has ended.
this.busy = false;
this.linesValue.push({ kind: 'busy', busy: false });
}
this.syncPrompt();
}
/** Takes the program's new lines into the window. */
pull(): void {
const terminal = this.terminal;
for (const entry of terminal.log.all.slice(this.seen)) {
if (entry.kind === 'text') this.linesValue.push({ kind: 'text', text: entry.text });
else {
this.busy = entry.busy;
this.linesValue.push({ kind: 'busy', busy: entry.busy });
}
}
this.seen = terminal.log.all.length;
this.directory = terminal.directory;
if (!this.busy) this.syncPrompt();
}
/** A line entered in the window. */
enter(line: string): void {
const terminal = this.terminal;
this.pull();
if (blank(line)) {
this.linesValue.push({ kind: 'text', text: ' \n' });
return;
}
const prompt = this.prompt ?? terminal.prompt;
const action = terminal.enter(line);
this.linesValue.push({ kind: 'input', prompt, line, command: action === undefined });
if (action === 'exit') this.linesValue.push({ kind: 'exit' });
if (action === 'clear') this.linesValue.push({ kind: 'clear' }, { kind: 'text', text: terminal.startScreen });
this.pull();
}
/** Ctrl+C in the window. */
interrupt(): void {
this.terminal.interrupt();
}
private syncPrompt(): void {
const prompt = this.terminal.prompt;
if (prompt === this.prompt) return;
this.prompt = prompt;
this.linesValue.push({ kind: 'prompt', prompt });
}
}