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: [] }));