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
+216
View File
@@ -0,0 +1,216 @@
// The room's command line window against Java's (tools/java-oracle
// TerminalWindowRun): the same keys typed into a computer's window, the same
// text, prompt and history after each, over a stop and a start. One
// difference is named: what a run made in the files is not yet written back
// into the document (F4.6f), so after the restart the folder made before is
// gone and the window goes on at the root, where Java's goes on in it.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { addDevice, emptyProject, ROOTS, writeProject } from '@mdg-filius/doc';
import { historyLine, historyStep, terminalScreen, type HostMessage, type TerminalLine } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
interface JavaStep {
readonly step: string;
readonly text: string;
readonly prompt: string;
readonly promptShown: boolean;
readonly input: string;
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'terminalWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
/** A computer with a command line, in a room on a clock the test moves, and a client that keeps the window's lines. */
function setUp(programs: readonly string[] = ['commandPrompt']) {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
const installed = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map<Y.Map<unknown>>;
for (const program of programs) {
const entry = new Y.Map<unknown>();
entry.set('active', true);
installed.set(program, entry);
}
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
let lines: TerminalLine[] = [];
const read = () => {
for (const message of got.splice(0)) {
if (message.kind !== 'app' || message.app !== 'terminal') continue;
expect(message.from).toBe(message.from === 0 ? 0 : lines.length);
lines = [...lines.slice(0, message.from), ...(message.lines as TerminalLine[])];
}
return lines;
};
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
return { doc, room, send, run, read, got, connect: (listener: (message: HostMessage) => void) => room.connect(listener) };
}
/** Until the files of a run are written back (F4.6f): the folder made before the restart is gone, the window goes on at the root. */
const FILES_NOT_KEPT_YET = (text: string) => text.replaceAll('/dir', '/');
describe("a command line's window", () => {
it("shows what Java's window shows, key by key, over a stop and a start", () => {
const { send, run, read } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
let pointer = -1;
let input = '';
let restartedAt: number | undefined;
for (const step of java.steps) {
if (step.step.startsWith('enter:')) {
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step });
run(400);
pointer = -1;
input = '';
} else if (step.step === 'up' || step.step === 'down') {
const history = terminalScreen(read()).history;
pointer = historyStep(pointer, step.step, history.length);
input = historyLine(history, pointer);
} else if (step.step === 'restart') {
send({ kind: 'stop' });
send({ kind: 'start' });
run(400);
restartedAt = terminalScreen(read()).text.length;
}
const screen = terminalScreen(read());
const expected =
restartedAt === undefined
? step
: { ...step, text: step.text.slice(0, restartedAt) + FILES_NOT_KEPT_YET(step.text.slice(restartedAt)), prompt: FILES_NOT_KEPT_YET(step.prompt) };
expect({ text: screen.text, prompt: screen.prompt, promptShown: screen.promptShown, input }, step.step).toEqual({
text: expected.text,
prompt: expected.prompt,
promptShown: expected.promptShown,
input: expected.input,
});
}
expect(read().at(-1)).toEqual({ kind: 'exit' });
});
it('keeps the working directory over a stop and a start, where it is still a folder', () => {
const { send, run, read, doc } = setUp();
const files = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('files') as Y.Map<Y.Map<unknown>>;
const folder = new Y.Map<unknown>();
folder.set('order', files.size);
folder.set('kind', 'directory');
files.set('/docs', folder);
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
// A folder the project has, so that the next start's file system has it too.
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cd /docs' }, reqId: 'a' });
run(400);
expect(terminalScreen(read()).prompt).toBe('/docs> ');
send({ kind: 'stop' });
send({ kind: 'start' });
run(400);
expect(terminalScreen(read()).prompt).toBe('/docs> ');
// The folder gone from the project: the next start goes on at the root.
files.delete('/docs');
send({ kind: 'stop' });
send({ kind: 'start' });
run(400);
expect(terminalScreen(read()).prompt).toBe('/> ');
});
it('hides the prompt while a command runs on, and Ctrl+C ends it', () => {
const { send, run, read } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' });
run(1_000);
expect(terminalScreen(read()).promptShown).toBe(false);
expect(read().some((line) => line.kind === 'busy' && line.busy)).toBe(true);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'interrupt', args: {}, reqId: 'b' });
run(15_000);
expect(terminalScreen(read()).promptShown).toBe(true);
expect(read().filter((line) => line.kind === 'busy').at(-1)).toEqual({ kind: 'busy', busy: false });
});
it('shows the prompt again after a stop that cut a command off', () => {
const { send, run, read } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' });
run(1_000);
expect(terminalScreen(read()).promptShown).toBe(false);
send({ kind: 'stop' });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(terminalScreen(read()).promptShown).toBe(true);
});
it('takes no line once its computer has no command line any more, and keeps what it showed', () => {
const { doc, send, run, read, got } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' });
run(400);
const shown = terminalScreen(read()).text;
send({ kind: 'stop' });
(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map<unknown>).delete('commandPrompt');
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' });
expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]);
expect(terminalScreen(read()).text).toBe(shown);
});
it('is the same window for everyone who opens it, and a client that comes later gets all of it', () => {
const { room, send, run, read, connect } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' });
run(400);
const later: HostMessage[] = [];
const other = connect((message) => later.push(message));
room.receive(other, { v: 1, kind: 'sub', topics: ['app:pc/terminal'] });
const first = read();
expect(first.some((line) => line.kind === 'input' && line.line === 'pwd')).toBe(true);
expect(later.filter((message) => message.kind === 'app')).toEqual([{ v: 1, kind: 'app', node: 'pc', app: 'terminal', from: 0, lines: first }]);
// What the later client types, the first one sees.
room.receive(other, { v: 1, kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' });
run(400);
expect(read().some((line) => line.kind === 'input' && line.line === 'ls')).toBe(true);
});
it('has no window on a computer without a command line, and takes no line while the simulation stands', () => {
const { send, run, read, got } = setUp([]);
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(read()).toEqual([]);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' });
expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'a' }]);
});
});
describe("the window's history", () => {
it("steps as Java's arrow keys do, never beyond its ends", () => {
expect(historyStep(-1, 'up', 0)).toBe(-1);
expect(historyStep(-1, 'up', 2)).toBe(0);
expect(historyStep(1, 'up', 2)).toBe(1);
expect(historyStep(0, 'down', 2)).toBe(-1);
expect(historyStep(-1, 'down', 2)).toBe(-1);
expect([historyLine(['a', 'b'], -1), historyLine(['a', 'b'], 0), historyLine(['a', 'b'], 1)]).toEqual(['', 'b', 'a']);
});
});