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:
@@ -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: [] }));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user