diff --git a/.agents/skills/testing-docs-site/SKILL.md b/.agents/skills/testing-docs-site/SKILL.md
index 1b36b5b9a0..4b8e55e43c 100644
--- a/.agents/skills/testing-docs-site/SKILL.md
+++ b/.agents/skills/testing-docs-site/SKILL.md
@@ -46,6 +46,27 @@ messages, reload, and confirm the loaded file carries the intended rule before
diagnosing a CSS fix as ineffective. Do not change repository configuration solely
to make a preview work.
+### The in-browser engines
+
+The landing diagram and the CLI page's REPL load generated assets: `docs/assets/sysml-engine.wasm.gz`,
+`sysml-repl.wasm.gz`, `wasm_exec.js` and `repl-examples/`. They are gitignored; if any is missing,
+run `make docs-engine-assets` before building the site.
+
+The REPL walkthroughs' inputs and expected output strings live in `docs/assets/repl-walkthroughs.json`.
+Run each step through its button and check only newly appended terminal output. Wait for the input
+queue to drain and `is-busy` to clear before sending the next command.
+
+To check the SMT refusal, declare a valid constraint first, for example
+`constraint def Positive { in x : ScalarValues::Integer; x > 0 }`, then run `%check Positive`. Expect
+the message that a WebAssembly build cannot start external processes.
+
+Completion considers every matching name, standard library included. `%inst` has several candidates;
+`%instanti` has one. For a shared-prefix test, pick names that no library name shares.
+
+At mobile width, navigate through the header menu. `label[for="__drawer"]` also matches a hidden
+overlay, so scope automation to `header label[for="__drawer"]`. Compare the document's width with the
+viewport's; a code textarea's own horizontal scrolling is not page overflow.
+
### Devin Secrets Needed
None for the public local site and public outbound link checks.
diff --git a/.gitignore b/.gitignore
index ebf801fe93..a33f3f965b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,8 @@
# In-browser engine assets (make docs-engine-assets); built, never vendored
/docs/assets/sysml-engine.wasm*
+/docs/assets/sysml-repl.wasm*
+/docs/assets/repl-examples/
/docs/assets/wasm_exec.js
# Julia package environments resolve locally; the pinned deps are in Project.toml.
diff --git a/Makefile b/Makefile
index c8499976c4..4d159cf600 100644
--- a/Makefile
+++ b/Makefile
@@ -503,8 +503,12 @@ docs-engine-assets: ## Build the in-browser engine assets into docs/assets
@mkdir -p docs/assets
GOOS=js GOARCH=wasm $(GO_BUILD) -o docs/assets/sysml-engine.wasm ./cmd/sysml-engine
gzip -9f docs/assets/sysml-engine.wasm
+ GOOS=js GOARCH=wasm $(GO_BUILD) -tags sysml_prod -o docs/assets/sysml-repl.wasm ./cmd/sysml
+ gzip -9f docs/assets/sysml-repl.wasm
+ @mkdir -p docs/assets/repl-examples
+ @cp examples/runtime-showcase/*.sysml docs/assets/repl-examples/
@cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" docs/assets/
- @echo "✓ Built docs/assets/sysml-engine.wasm.gz + wasm_exec.js"
+ @echo "✓ Built docs/assets/sysml-engine.wasm.gz, sysml-repl.wasm.gz, repl-examples/ + wasm_exec.js"
docs-serve: docs-install ## Serve the documentation site with live reload
$(PYTHON) -m mkdocs serve --strict
diff --git a/changes/unreleased/browser-repl.added.md b/changes/unreleased/browser-repl.added.md
new file mode 100644
index 0000000000..22dbce8b78
--- /dev/null
+++ b/changes/unreleased/browser-repl.added.md
@@ -0,0 +1,12 @@
+- **The CLI page runs the real `sysml` REPL in the browser, with guided walkthroughs.** The
+ page's toy shell over `sysml-engine` is replaced by `cmd/sysml` itself, built for js/wasm
+ with `-tags sysml_prod` (about 12 MB gzipped, downloaded when started), on an in-memory
+ filesystem holding the runtime-showcase models. Seven walkthroughs type their commands into
+ the prompt: units, the Saturn V mass rollup, the delta-v analysis and sweep, failing
+ requirements, stepping an action, a state machine on a clock and a user-written one. Up and
+ Down recall earlier entries, kept across visits, and Tab completes through the REPL's own
+ completer. `TestBrowserREPLWalkthroughs` runs every walkthrough through the page's host.
+- **The landing-page diagram can be edited.** "Edit the model" opens the diagram's SysML
+ source; the engine re-parses and re-instantiates it as you type and redraws the parts and
+ interfaces, a parse error is shown with its line and column while the last good diagram
+ stays up, and "Run the model" executes the edited model.
diff --git a/cmd/sysml/host_js.go b/cmd/sysml/host_js.go
new file mode 100644
index 0000000000..f109a91786
--- /dev/null
+++ b/cmd/sysml/host_js.go
@@ -0,0 +1,34 @@
+//go:build js
+
+package main
+
+import (
+ "encoding/json"
+ "syscall/js"
+
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+)
+
+// hostInterruptLine is the line a page sends to interrupt the REPL (Ctrl-C),
+// discarding an unfinished declaration.
+const hostInterruptLine = "\x03"
+
+// exposeCompletion lets a page complete the line being typed:
+// globalThis.sysmlReplComplete(textBeforeCursor) answers {candidates, prefix} as JSON.
+func exposeCompletion(sess *repl.Session) {
+ js.Global().Set("sysmlReplComplete", js.FuncOf(func(_ js.Value, args []js.Value) any {
+ if len(args) == 0 || args[0].Type() != js.TypeString {
+ return "{}"
+ }
+ line := args[0].String()
+ c := sess.Complete(line, len(line))
+ out, err := json.Marshal(struct {
+ Candidates []string `json:"candidates"`
+ Prefix string `json:"prefix"`
+ }{c.Candidates, c.Prefix})
+ if err != nil {
+ return "{}"
+ }
+ return string(out)
+ }))
+}
diff --git a/cmd/sysml/host_wasip1.go b/cmd/sysml/host_wasip1.go
new file mode 100644
index 0000000000..87e4f6a95f
--- /dev/null
+++ b/cmd/sysml/host_wasip1.go
@@ -0,0 +1,11 @@
+//go:build wasip1
+
+package main
+
+import "github.com/Open-MBEE/OpenSysML/internal/frontend/repl"
+
+// hostInterruptLine is empty: WASI input is a plain stream with no interrupt.
+const hostInterruptLine = ""
+
+// exposeCompletion has no host to expose completion to under WASI.
+func exposeCompletion(*repl.Session) {}
diff --git a/cmd/sysml/prompt_plain.go b/cmd/sysml/prompt_plain.go
index 08d6c7b66d..cd8333bb8e 100644
--- a/cmd/sysml/prompt_plain.go
+++ b/cmd/sysml/prompt_plain.go
@@ -14,17 +14,19 @@ import (
// newLineInput opens the prompt's line reader over standard input. A WebAssembly
// host has no line editor to hand: lines arrive one at a time from whatever the
-// host wired up, without history, completion or an interrupt to catch. The session
-// is passed for the signature it has on every build and is not consulted. The
-// returned function closes the reader, which is nothing to do over standard input.
-func newLineInput(_ *repl.Session) (repl.LineReader, func() error, error) {
- return &plainReader{in: bufio.NewReader(os.Stdin), out: os.Stdout}, func() error { return nil }, nil
+// host wired up. A browser page gets completion through the session and may send
+// hostInterruptLine for Ctrl-C. The returned function closes the reader, which is
+// nothing to do over standard input.
+func newLineInput(sess *repl.Session) (repl.LineReader, func() error, error) {
+ exposeCompletion(sess)
+ return &plainReader{in: bufio.NewReader(os.Stdin), out: os.Stdout, interrupt: hostInterruptLine}, func() error { return nil }, nil
}
// plainReader yields the lines it is read from, writing each prompt before it waits.
type plainReader struct {
- in *bufio.Reader
- out io.Writer
+ in *bufio.Reader
+ out io.Writer
+ interrupt string // a line that reads as repl.ErrInterrupt, when nonempty
}
// ReadLine writes the prompt and reads the next line, without its line ending, and
@@ -43,7 +45,11 @@ func (r *plainReader) ReadLine(prompt string) (string, error) {
return "", io.EOF
}
}
- return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
+ line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
+ if r.interrupt != "" && line == r.interrupt {
+ return "", repl.ErrInterrupt
+ }
+ return line, nil
}
// isTerminal reports whether the file descriptor is a terminal: no WebAssembly host
diff --git a/docs/assets/repl-walkthroughs.json b/docs/assets/repl-walkthroughs.json
new file mode 100644
index 0000000000..e3d08945f3
--- /dev/null
+++ b/docs/assets/repl-walkthroughs.json
@@ -0,0 +1,470 @@
+{
+ "tours": [
+ {
+ "id": "basics",
+ "title": "First steps",
+ "steps": [
+ {
+ "title": "An expression is a command",
+ "text": "Type any SysML expression at the prompt and the REPL evaluates it. There is nothing to set up first.",
+ "input": [
+ "2 + 3 * 4"
+ ],
+ "expect": [
+ "= 14"
+ ]
+ },
+ {
+ "title": "Quantities carry their units",
+ "text": "Write a unit in brackets after a number. The REPL takes the units from the standard SI library and converts the result to SI.",
+ "input": [
+ "100 [SI::km] / 2 [SI::h]"
+ ],
+ "expect": [
+ "= 13.88888888888889 [SI::'m/s']"
+ ]
+ },
+ {
+ "title": "and the units are checked",
+ "text": "You cannot add a mass to a length. The REPL refuses the sum and names both units.",
+ "input": [
+ "1 [SI::kg] + 1 [SI::m]"
+ ],
+ "expect": [
+ "incommensurable units"
+ ]
+ },
+ {
+ "title": "Declare a model at the prompt",
+ "text": "A declaration typed at the prompt joins the session model. A block that is still open shows the ...> continuation prompt. The warning is real: the library's sum is declared over Real, and this model gives it masses.",
+ "input": [
+ "package Garage {",
+ " private import SI::*;",
+ " private import RealFunctions::sum;",
+ " part def Wheel { attribute mass :> ISQ::mass = 25 [kg]; }",
+ " part car {",
+ " part wheels : Wheel[4];",
+ " attribute bodyMass :> ISQ::mass = 1200 [kg];",
+ " attribute mass :> ISQ::mass = bodyMass + sum(wheels.mass);",
+ " }",
+ "}"
+ ],
+ "expect": [
+ "✓ package Garage"
+ ]
+ },
+ {
+ "title": "Derived values are evaluated on an instance",
+ "text": "car.mass is never written down anywhere. To evaluate it, the runtime makes an instance of car with four wheels and sums their masses into the body's.",
+ "input": [
+ "%eval Garage::car.mass"
+ ],
+ "expect": [
+ "= 1300 [kg]"
+ ]
+ },
+ {
+ "title": "Hold an object and query it",
+ "text": "%instantiate keeps the object in the session. %eval in evaluates an expression with that object as its context.",
+ "input": [
+ "%instantiate Garage::car",
+ "%eval in Garage::car : mass / 4"
+ ],
+ "expect": [
+ "Created instance of Garage::car",
+ "= 325.0 [kg]"
+ ]
+ },
+ {
+ "title": "Calculations with typed parameters",
+ "text": "A calc def is a function whose parameters are typed. %calc calls it with quantities, and the result comes back in SI.",
+ "input": [
+ "calc def Speed { in d : ISQ::LengthValue; in t : ISQ::TimeValue; return : ISQ::SpeedValue = d / t; }",
+ "%calc Speed 42.195 [SI::km] 2 [SI::h]"
+ ],
+ "expect": [
+ "= 5.860416666666667 [SI::'m/s']"
+ ]
+ },
+ {
+ "title": "Look around",
+ "text": "%list prints what the session has declared. %search looks up names in the session and the standard library. Press Tab at the prompt to complete commands and names, and ↑ to bring back earlier lines.",
+ "input": [
+ "%list",
+ "%search Wheel"
+ ],
+ "expect": [
+ "Garage::Wheel part def"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "mass",
+ "title": "Saturn V mass rollup",
+ "steps": [
+ {
+ "title": "Load a model from the examples",
+ "text": "The page carries the runtime showcase models in an in-memory filesystem, so %load works the way it does on a disk. MassRollup defines totalMass recursively: a component's own mass plus the totalMass of each of its subcomponents.",
+ "input": [
+ "%load examples/runtime-showcase/mass-rollup.sysml"
+ ],
+ "expect": [
+ "✓ package MassRollup"
+ ]
+ },
+ {
+ "title": "Roll up three stages and eleven engines",
+ "text": "No totalMass value appears anywhere in the file. Evaluating it builds the whole vehicle and computes the value at every level.",
+ "input": [
+ "%eval MassRollup::saturnV.totalMass",
+ "%eval MassRollup::saturnV.stage1.totalMass"
+ ],
+ "expect": [
+ "= 2941728.0 [kg]",
+ "= 2332000.0 [kg]"
+ ]
+ },
+ {
+ "title": "A gap that only the runtime finds",
+ "text": "saturnVWithIU adds an instrument unit that declares a mass but never gives it a value. That is legal SysML, so validation finds nothing wrong. The rollup is where the missing value shows up.",
+ "input": [
+ "%eval MassRollup::saturnVWithIU.totalMass"
+ ],
+ "expect": [
+ "no value for feature mass"
+ ]
+ },
+ {
+ "title": "Print the element at fault",
+ "text": "%print prints any element as SysML notation, comments included.",
+ "input": [
+ "%print MassRollup::InstrumentUnit"
+ ],
+ "expect": [
+ "part def InstrumentUnit"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "orbit",
+ "title": "Will it reach orbit?",
+ "steps": [
+ {
+ "title": "A delta-v budget",
+ "text": "DeltaVBudget applies the rocket equation to each stage of a Saturn IB and checks the total against an orbit requirement. The warning on load is genuine: the model calls the rocket equation in a way that leaves one parameter unbound.",
+ "input": [
+ "%load examples/runtime-showcase/delta-v-budget.sysml"
+ ],
+ "expect": [
+ "✓ package DeltaVBudget"
+ ]
+ },
+ {
+ "title": "Call the rocket equation",
+ "text": "Specific impulse, standard gravity, wet mass and dry mass go in. Metres per second come out, because the units are carried through every step.",
+ "input": [
+ "%calc DeltaVBudget::RocketEquation 421 [SI::s] 9.80665 [SI::'m/s²'] 133000 [SI::kg] 28000 [SI::kg]"
+ ],
+ "expect": [
+ "= 6432.955324716369 [SI::'m/s']"
+ ]
+ },
+ {
+ "title": "Run the analysis case",
+ "text": "The analysis computes each stage's delta-v and the margin over the required speed, then decides its objective: the OrbitWithMargin requirement.",
+ "input": [
+ "%analysis DeltaVBudget::saturnIBAscent"
+ ],
+ "expect": [
+ "objective reachesOrbit: satisfied"
+ ]
+ },
+ {
+ "title": "Ask for more",
+ "text": "Arguments rebind the case's inputs. Require 400 m/s more and the verdict flips. The REPL prints the condition that failed.",
+ "input": [
+ "%analysis DeltaVBudget::saturnIBAscent(required = 9800 ['m/s'])"
+ ],
+ "expect": [
+ "objective reachesOrbit: not satisfied: margin > 0 ['m/s']"
+ ]
+ },
+ {
+ "title": "Sweep the requirement",
+ "text": "%sweep runs the case once for each value in a range and prints one row per run. The verdict changes between 9 250 and 9 500 m/s.",
+ "input": [
+ "%sweep DeltaVBudget::saturnIBAscent required=9000.0 ['m/s']..10000.0 ['m/s']:250.0 ['m/s']"
+ ],
+ "expect": [
+ "5 run(s)",
+ "9500.0 ['m/s']"
+ ]
+ },
+ {
+ "title": "Check every satisfy claim",
+ "text": "%satisfy evaluates each assert satisfy in the model against an instance of its subject.",
+ "input": [
+ "%satisfy"
+ ],
+ "expect": [
+ "satisfy saturnIBOrbit by saturnIB holds"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "reliability",
+ "title": "Requirements that fail",
+ "steps": [
+ {
+ "title": "A crew-safety requirement",
+ "text": "Reliability multiplies e^(-λt) over the craft's components and requires the product to be at least 0.99. apollo flies for 195 hours. longStay flies the same hardware for 84 days.",
+ "input": [
+ "%load examples/runtime-showcase/reliability.sysml",
+ "%eval Reliability::apollo.reliability",
+ "%eval Reliability::longStay.reliability"
+ ],
+ "expect": [
+ "= 0.9902201369662298",
+ "= 0.9033850540586944"
+ ]
+ },
+ {
+ "title": "Decide the requirement",
+ "text": "One mission meets the requirement and the other does not. The REPL reports the condition that was violated and the instance it was decided on.",
+ "input": [
+ "%satisfy"
+ ],
+ "expect": [
+ "satisfy crewSafety by apollo holds",
+ "satisfy crewSafety by longStay fails",
+ "Required condition evaluated to false: craft.reliability >= threshold"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "mission",
+ "title": "Step through Apollo 11",
+ "steps": [
+ {
+ "title": "An action flow with a decision",
+ "text": "LunarMission is a sequence of burns that spends a delta-v budget, with a decide node that skips the landing if too little is left after lunar orbit insertion.",
+ "input": [
+ "%load examples/runtime-showcase/mission-sequence.sysml"
+ ],
+ "expect": [
+ "✓ package MissionSequence"
+ ]
+ },
+ {
+ "title": "Start the action on an object",
+ "text": "Apollo 11 performs the mission. %action starts a debugger on the action, which waits with one token at the start.",
+ "input": [
+ "%instantiate MissionSequence::apollo11",
+ "%action MissionSequence::Flight::mission MissionSequence::apollo11"
+ ],
+ "expect": [
+ "Started action executor"
+ ]
+ },
+ {
+ "title": "Step it and look at the token",
+ "text": "Each %step moves the token one node. %tokens shows where the token is and the value of remaining so far.",
+ "input": [
+ "%step",
+ "%step",
+ "%tokens"
+ ],
+ "expect": [
+ "Token 1 @ lunarOrbitInsertion",
+ "remaining = 6350 ['m/s']"
+ ]
+ },
+ {
+ "title": "Run it to the end",
+ "text": "Enough delta-v is left, so the decision takes the landing branch.",
+ "input": [
+ "%continue"
+ ],
+ "expect": [
+ "landed = true",
+ "remaining = 480 ['m/s']"
+ ]
+ },
+ {
+ "title": "Apollo 8 takes the other branch",
+ "text": "Apollo 8 carries 4 000 m/s less. The same flow run on that object skips the landing.",
+ "input": [
+ "%instantiate MissionSequence::apollo8",
+ "%action MissionSequence::Flight::mission MissionSequence::apollo8",
+ "%continue"
+ ],
+ "expect": [
+ "landed = false",
+ "remaining = 450 ['m/s']"
+ ]
+ },
+ {
+ "title": "The mission as a state machine on a clock",
+ "text": "MissionPhases moves through its phases with timed transitions, measured in seconds. %advance runs the simulation clock forward and reports where the machine is.",
+ "input": [
+ "%instantiate MissionSequence::mission",
+ "%state MissionSequence::mission",
+ "%advance 400000",
+ "%advance 400000"
+ ],
+ "expect": [
+ "Current state: onSurface",
+ "State machine completed"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "comms",
+ "title": "A spacecraft on a clock",
+ "steps": [
+ {
+ "title": "Signals, parallel regions and a battery",
+ "text": "A ground station pings a spacecraft 30 s into the mission. The spacecraft answers with one 1 024-byte frame a second, and a parallel region drains its battery 2 % per frame. Below 80 % the battery recharges, and below 40 % transmission is interrupted.",
+ "input": [
+ "%load examples/runtime-showcase/spacecraft-comms.sysml",
+ "%instantiate SpacecraftComms::mission",
+ "%state SpacecraftComms::mission.spacecraftVehicle"
+ ],
+ "expect": [
+ "Current state: waitingGSPing | notRecharging"
+ ]
+ },
+ {
+ "title": "Advance 40 seconds",
+ "text": "The ping has arrived and the spacecraft is transmitting. Read the battery level off the running object.",
+ "input": [
+ "%advance 40",
+ "%eval in SpacecraftComms::mission.spacecraftVehicle : battery"
+ ],
+ "expect": [
+ "Current state: transmitting | notRecharging",
+ "= 80"
+ ]
+ },
+ {
+ "title": "Advance another 40",
+ "text": "The battery has fallen below 40 %, so transmission is interrupted and the spacecraft goes into low power to recharge. The ground station has counted the frames that arrived.",
+ "input": [
+ "%advance 40",
+ "%eval in SpacecraftComms::mission.spacecraftVehicle : data",
+ "%eval in SpacecraftComms::mission.groundStation : framesReceived"
+ ],
+ "expect": [
+ "Current state: lowPower | recharging",
+ "= 51200",
+ "= 50"
+ ]
+ },
+ {
+ "title": "Run to the end of the transfer",
+ "text": "All 100 kB arrive by t = 241 s. %current also reports what the machine is still waiting for.",
+ "input": [
+ "%advance 220",
+ "%current"
+ ],
+ "expect": [
+ "Current state: transmitted | notRecharging",
+ "Cannot progress"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "own",
+ "title": "Your own state machine",
+ "steps": [
+ {
+ "title": "Write a state machine",
+ "text": "A lamp that a Toggle signal switches on and off, and that switches itself off after 5 seconds.",
+ "input": [
+ "package Lamp {",
+ " item def Toggle;",
+ " state def Switch {",
+ " entry; then off;",
+ " state off;",
+ " transition first off accept Toggle then on;",
+ " state on;",
+ " transition first on accept Toggle then off;",
+ " transition first on accept after 5 [SI::s] then off;",
+ " }",
+ " part lamp { exhibit state s : Switch; }",
+ "}"
+ ],
+ "expect": [
+ "✓ package Lamp"
+ ]
+ },
+ {
+ "title": "Start it",
+ "text": "%state attaches a debugger to the machine the object exhibits.",
+ "input": [
+ "%instantiate Lamp::lamp",
+ "%state Lamp::lamp"
+ ],
+ "expect": [
+ "Current state: off"
+ ]
+ },
+ {
+ "title": "Send it a signal",
+ "text": "%send posts the signal on the runtime's message bus, and advancing the clock dispatches it. The report shows the 5-second timer that is now pending.",
+ "input": [
+ "%send Toggle to Lamp::lamp",
+ "%advance 1"
+ ],
+ "expect": [
+ "Current state: on",
+ "t=5.0"
+ ]
+ },
+ {
+ "title": "Let the timer fire",
+ "text": "Nothing is sent this time. The timer started on entering on fires at t = 5 s and switches the lamp off.",
+ "input": [
+ "%advance 5"
+ ],
+ "expect": [
+ "Current state: off"
+ ]
+ },
+ {
+ "title": "Trace what the runtime does",
+ "text": "%trace on prints each exit, entry, evaluation and transition as it happens.",
+ "input": [
+ "%trace on",
+ "%send Toggle to Lamp::lamp",
+ "%advance 1",
+ "%trace off"
+ ],
+ "expect": [
+ "[trace] transition: off -> on"
+ ]
+ },
+ {
+ "title": "Save, clear and load again",
+ "text": "%save writes the session model as SysML notation to this page's in-memory disk. %clear empties the session, and %load reads the file back in.",
+ "input": [
+ "%save lamp.sysml",
+ "%clear",
+ "%load lamp.sysml",
+ "%list"
+ ],
+ "expect": [
+ "saved",
+ "✓ package Lamp"
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/assets/sysml-repl.js b/docs/assets/sysml-repl.js
new file mode 100644
index 0000000000..3d53cd7167
--- /dev/null
+++ b/docs/assets/sysml-repl.js
@@ -0,0 +1,683 @@
+// Runs the real sysml REPL (cmd/sysml built for js/wasm) in a page: an
+// in-memory filesystem stands in for the disk and stdin, and mount() draws a
+// terminal with history, completion and guided walkthroughs over it.
+(function (root) {
+ "use strict";
+
+ var enc = new TextEncoder();
+ var S_IFDIR = 0x4000, S_IFREG = 0x8000, S_IFCHR = 0x2000;
+ var C = { O_WRONLY: 1, O_RDWR: 2, O_CREAT: 64, O_EXCL: 128, O_TRUNC: 512, O_APPEND: 1024, O_DIRECTORY: 65536 };
+
+ function fsError(code) {
+ var e = new Error(code);
+ e.code = code;
+ return e;
+ }
+
+ function normalize(path) {
+ var parts = [];
+ String(path).split("/").forEach(function (p) {
+ if (!p || p === ".") return;
+ if (p === "..") parts.pop(); else parts.push(p);
+ });
+ return "/" + parts.join("/");
+ }
+
+ // MemFS answers the calls Go's syscall/fs_js makes of globalThis.fs, node's
+ // callback API, for one session: fd 0 is the stdin queue, 1 and 2 the output.
+ function MemFS(onOutput, onWaiting, defer) {
+ var self = this;
+ var ino = 1;
+ var nodes = { "/": { dir: true, ino: ino++, mtime: Date.now() } };
+ var fds = {}, nextFd = 3;
+ var stdin = [], pendingRead = null, eof = false;
+ var decoders = { 1: new TextDecoder(), 2: new TextDecoder() };
+ self.cwd = "/";
+
+ function abs(p) { return p.charAt(0) === "/" ? normalize(p) : normalize(self.cwd + "/" + p); }
+ function parent(p) { return normalize(p + "/.."); }
+ function stat(n) {
+ var size = n.dir || n.chr ? 0 : n.size;
+ return {
+ dev: 1, ino: n.ino, mode: (n.dir ? S_IFDIR | 493 : n.chr ? S_IFCHR | 438 : S_IFREG | 420),
+ nlink: 1, uid: 0, gid: 0, rdev: 0, size: size, blksize: 4096, blocks: Math.ceil(size / 512),
+ atimeMs: n.mtime, mtimeMs: n.mtime, ctimeMs: n.mtime,
+ isDirectory: function () { return !!n.dir; }
+ };
+ }
+ function mkdirs(p) {
+ if (nodes[p]) return;
+ mkdirs(parent(p));
+ nodes[p] = { dir: true, ino: ino++, mtime: Date.now() };
+ }
+ function children(p) {
+ var pre = p === "/" ? "/" : p + "/";
+ return Object.keys(nodes).filter(function (k) {
+ return k !== p && k.indexOf(pre) === 0 && k.slice(pre.length).indexOf("/") < 0;
+ }).map(function (k) { return k.slice(pre.length); }).sort();
+ }
+ function grow(n, size) {
+ if (n.data.length >= size) return;
+ var d = new Uint8Array(Math.max(size, n.data.length * 2, 256));
+ d.set(n.data.subarray(0, n.size));
+ n.data = d;
+ }
+ function output(fd, bytes) {
+ onOutput(decoders[fd].decode(bytes, { stream: true }), fd);
+ }
+ function deliver() {
+ if (!pendingRead) return;
+ if (!stdin.length) {
+ if (!eof) return;
+ var done = pendingRead;
+ pendingRead = null;
+ done.cb(null, 0);
+ return;
+ }
+ var r = pendingRead, chunk = stdin[0], n = Math.min(r.length, chunk.length);
+ pendingRead = null;
+ r.buffer.set(chunk.subarray(0, n), r.offset);
+ if (n < chunk.length) stdin[0] = chunk.subarray(n); else stdin.shift();
+ r.cb(null, n);
+ }
+
+ self.addFile = function (path, content) {
+ var p = abs(path);
+ mkdirs(parent(p));
+ var data = typeof content === "string" ? enc.encode(content) : new Uint8Array(content);
+ nodes[p] = { ino: ino++, data: data, size: data.length, mtime: Date.now() };
+ };
+ self.readFile = function (path) {
+ var n = nodes[abs(path)];
+ return n && !n.dir ? new TextDecoder().decode(n.data.subarray(0, n.size)) : null;
+ };
+ self.list = function () {
+ return Object.keys(nodes).filter(function (k) { return !nodes[k].dir; }).sort();
+ };
+ self.send = function (text) {
+ if (text) stdin.push(enc.encode(text));
+ defer(deliver);
+ };
+ self.eof = function () { eof = true; defer(deliver); };
+ self.waiting = function () { return !!pendingRead && !stdin.length; };
+
+ var ok = function () { var cb = arguments[arguments.length - 1]; cb(null); };
+ self.api = {
+ constants: C,
+ writeSync: function (fd, buf) {
+ if (fd === 1 || fd === 2) output(fd, buf);
+ return buf.length;
+ },
+ write: function (fd, buf, offset, length, position, cb) {
+ var bytes = buf.subarray(offset, offset + length);
+ if (fd === 1 || fd === 2) { output(fd, bytes); cb(null, length); return; }
+ var f = fds[fd];
+ if (!f || f.node.dir) { cb(fsError("EBADF")); return; }
+ var n = f.node, at = position != null ? position : f.append ? n.size : f.pos;
+ grow(n, at + length);
+ n.data.set(bytes, at);
+ n.size = Math.max(n.size, at + length);
+ n.mtime = Date.now();
+ if (position == null) f.pos = at + length;
+ cb(null, length);
+ },
+ read: function (fd, buffer, offset, length, position, cb) {
+ if (fd === 0) {
+ pendingRead = { buffer: buffer, offset: offset, length: length, cb: cb };
+ if (stdin.length || eof) deliver(); else onWaiting();
+ return;
+ }
+ var f = fds[fd];
+ if (!f) { cb(fsError("EBADF")); return; }
+ if (f.node.dir) { cb(fsError("EISDIR")); return; }
+ var at = position != null ? position : f.pos, n = Math.max(0, Math.min(length, f.node.size - at));
+ buffer.set(f.node.data.subarray(at, at + n), offset);
+ if (position == null) f.pos = at + n;
+ cb(null, n);
+ },
+ open: function (path, flags, mode, cb) {
+ var p = abs(path), n = nodes[p];
+ if (!n) {
+ if (!(flags & C.O_CREAT)) { cb(fsError("ENOENT")); return; }
+ var dir = nodes[parent(p)];
+ if (!dir) { cb(fsError("ENOENT")); return; }
+ if (!dir.dir) { cb(fsError("ENOTDIR")); return; }
+ n = nodes[p] = { ino: ino++, data: new Uint8Array(0), size: 0, mtime: Date.now() };
+ } else if ((flags & C.O_CREAT) && (flags & C.O_EXCL)) {
+ cb(fsError("EEXIST")); return;
+ }
+ if (n.dir && (flags & (C.O_WRONLY | C.O_RDWR))) { cb(fsError("EISDIR")); return; }
+ if (!n.dir && (flags & C.O_DIRECTORY)) { cb(fsError("ENOTDIR")); return; }
+ if (!n.dir && (flags & C.O_TRUNC)) n.size = 0;
+ var fd = nextFd++;
+ fds[fd] = { node: n, pos: 0, append: !!(flags & C.O_APPEND) };
+ cb(null, fd);
+ },
+ close: function (fd, cb) { delete fds[fd]; cb(null); },
+ fstat: function (fd, cb) {
+ if (fd <= 2) { cb(null, stat({ chr: true, ino: fd + 1000, mtime: Date.now() })); return; }
+ var f = fds[fd];
+ if (!f) cb(fsError("EBADF")); else cb(null, stat(f.node));
+ },
+ stat: function (path, cb) {
+ var n = nodes[abs(path)];
+ if (!n) cb(fsError("ENOENT")); else cb(null, stat(n));
+ },
+ lstat: function (path, cb) { self.api.stat(path, cb); },
+ readdir: function (path, cb) {
+ var p = abs(path), n = nodes[p];
+ if (!n) cb(fsError("ENOENT")); else if (!n.dir) cb(fsError("ENOTDIR")); else cb(null, children(p));
+ },
+ mkdir: function (path, perm, cb) {
+ var p = abs(path);
+ if (nodes[p]) { cb(fsError("EEXIST")); return; }
+ if (!nodes[parent(p)]) { cb(fsError("ENOENT")); return; }
+ nodes[p] = { dir: true, ino: ino++, mtime: Date.now() };
+ cb(null);
+ },
+ rename: function (from, to, cb) {
+ var a = abs(from), b = abs(to);
+ if (!nodes[a]) { cb(fsError("ENOENT")); return; }
+ if (!nodes[parent(b)]) { cb(fsError("ENOENT")); return; }
+ Object.keys(nodes).forEach(function (k) {
+ if (k === a || k.indexOf(a + "/") === 0) {
+ nodes[b + k.slice(a.length)] = nodes[k];
+ delete nodes[k];
+ }
+ });
+ cb(null);
+ },
+ unlink: function (path, cb) {
+ var p = abs(path), n = nodes[p];
+ if (!n) cb(fsError("ENOENT")); else if (n.dir) cb(fsError("EISDIR")); else { delete nodes[p]; cb(null); }
+ },
+ rmdir: function (path, cb) {
+ var p = abs(path), n = nodes[p];
+ if (!n) cb(fsError("ENOENT"));
+ else if (!n.dir) cb(fsError("ENOTDIR"));
+ else if (children(p).length) cb(fsError("ENOTEMPTY"));
+ else { delete nodes[p]; cb(null); }
+ },
+ ftruncate: function (fd, len, cb) {
+ var f = fds[fd];
+ if (!f || f.node.dir) { cb(fsError("EBADF")); return; }
+ grow(f.node, len);
+ if (len > f.node.size) f.node.data.fill(0, f.node.size, len);
+ f.node.size = len;
+ cb(null);
+ },
+ truncate: function (path, len, cb) {
+ self.api.open(path, C.O_WRONLY, 0, function (err, fd) {
+ if (err) { cb(err); return; }
+ self.api.ftruncate(fd, len, function (e) { delete fds[fd]; cb(e); });
+ });
+ },
+ fsync: ok, chmod: ok, fchmod: ok, chown: ok, fchown: ok, lchown: ok, utimes: ok,
+ readlink: function (p, cb) { cb(fsError("EINVAL")); },
+ link: function (a, b, cb) { cb(fsError("ENOSYS")); },
+ symlink: function (a, b, cb) { cb(fsError("ENOSYS")); }
+ };
+ self.process = {
+ getuid: function () { return -1; }, getgid: function () { return -1; },
+ geteuid: function () { return -1; }, getegid: function () { return -1; },
+ getgroups: function () { throw fsError("ENOSYS"); },
+ pid: -1, ppid: -1,
+ umask: function () { return 18; },
+ cwd: function () { return self.cwd; },
+ chdir: function (d) {
+ var p = abs(d);
+ if (!nodes[p] || !nodes[p].dir) throw fsError("ENOENT");
+ self.cwd = p;
+ }
+ };
+ self.path = { resolve: function () { return abs(Array.prototype.slice.call(arguments).join("/")); } };
+ }
+
+ var modules = {};
+
+ // compile fetches a gzipped binary once per URL, reporting bytes as they arrive.
+ function compile(url, onProgress) {
+ if (!modules[url]) {
+ modules[url] = fetch(url).then(function (r) {
+ if (!r.ok) throw new Error("the REPL did not download (HTTP " + r.status + ")");
+ if (typeof DecompressionStream === "undefined") throw new Error("this browser cannot inflate the REPL");
+ var total = +r.headers.get("Content-Length") || 0, got = 0;
+ var counted = r.body.pipeThrough(new TransformStream({
+ transform: function (chunk, ctl) {
+ got += chunk.length;
+ if (onProgress) onProgress(got, total);
+ ctl.enqueue(chunk);
+ }
+ }));
+ return new Response(counted.pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
+ }).then(function (bytes) { return WebAssembly.compile(bytes); });
+ modules[url].catch(function () { delete modules[url]; });
+ }
+ return modules[url];
+ }
+
+ // start runs one REPL process on module. Go reads globalThis.fs, process and
+ // path once, at init, so they are swapped in for that synchronous start only.
+ function start(opts) {
+ var defer = opts.defer || function (fn) { setTimeout(fn, 0); };
+ var vfs = new MemFS(opts.onOutput, opts.onWaiting || function () {}, defer);
+ Object.keys(opts.files || {}).forEach(function (p) { vfs.addFile(p, opts.files[p]); });
+ if (opts.cwd) vfs.cwd = normalize(opts.cwd);
+ var go = new Go();
+ var code = 0;
+ go.argv = ["sysml"].concat(opts.args || []);
+ go.env = Object.assign({ HOME: "/home/visitor", TERM: "dumb", OPENSYSML_RECORD_CACHE: "0" }, opts.env || {});
+ var exit = go.exit;
+ go.exit = function (c) { code = c; exit.call(go, c); };
+ return WebAssembly.instantiate(opts.module, go.importObject).then(function (instance) {
+ var saved = { fs: globalThis.fs, process: globalThis.process, path: globalThis.path };
+ globalThis.fs = vfs.api;
+ globalThis.process = vfs.process;
+ globalThis.path = vfs.path;
+ var done;
+ try {
+ done = go.run(instance);
+ } finally {
+ globalThis.fs = saved.fs;
+ globalThis.process = saved.process;
+ globalThis.path = saved.path;
+ }
+ var session = {
+ fs: vfs,
+ send: vfs.send,
+ eof: vfs.eof,
+ // interrupt is Ctrl-C: the REPL drops the declaration it is buffering.
+ interrupt: function () { vfs.send(INTERRUPT_LINE + "\n"); },
+ waiting: vfs.waiting,
+ exited: false,
+ done: null,
+ // The REPL installs sysmlReplComplete once its session exists, which
+ // can be after go.run yields, so it is looked up on each request.
+ complete: function (head) {
+ var complete = globalThis.sysmlReplComplete;
+ if (typeof complete !== "function" || session.exited || !vfs.waiting()) return null;
+ try { return JSON.parse(complete(head)); } catch (e) { return null; }
+ }
+ };
+ session.done = Promise.resolve(done).then(function () {
+ session.exited = true;
+ return code;
+ });
+ return session;
+ });
+ }
+
+ function loadScript(src) {
+ if (typeof Go === "function") return Promise.resolve();
+ return new Promise(function (resolve, reject) {
+ var s = document.createElement("script");
+ s.src = src;
+ s.onload = resolve;
+ s.onerror = function () { reject(new Error("wasm_exec.js did not load")); };
+ document.head.appendChild(s);
+ });
+ }
+
+ var INTERRUPT_LINE = "\u0003";
+ var HISTORY_KEY = "osml-repl-history", HISTORY_MAX = 500, OUTPUT_MAX = 4000;
+
+ function loadHistory() {
+ try {
+ var h = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
+ return Array.isArray(h) ? h.filter(function (x) { return typeof x === "string"; }) : [];
+ } catch (e) { return []; }
+ }
+ function saveHistory(h) {
+ try { localStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(-HISTORY_MAX))); } catch (e) { /* private mode */ }
+ }
+
+ function lineClass(line) {
+ if (/^\s*(✓|=\s)/.test(line)) return "osml-ok";
+ if (/^\s*✗|^\s*error:|^sysml: |: error: /.test(line)) return "osml-err";
+ if (/: warning: |^\s*warning:/.test(line)) return "osml-hl";
+ if (/^\s+(standing:|ID: |Use %)/.test(line)) return "osml-dim";
+ return "";
+ }
+
+ function el(tag, cls, text) {
+ var e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ }
+
+ // mount wires the terminal markup inside rootEl (see docs/cli.md) to a REPL.
+ function mount(rootEl) {
+ var d = rootEl.dataset;
+ var out = rootEl.querySelector("[data-repl-out]");
+ var input = rootEl.querySelector("[data-repl-in]");
+ var promptEl = rootEl.querySelector("[data-repl-prompt]");
+ var statusEl = rootEl.querySelector("[data-repl-status]");
+ var startBtn = rootEl.querySelector("[data-repl-start]");
+ var toursEl = rootEl.querySelector("[data-repl-tours]");
+ var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ var session = null, starting = null, partial = "", history = loadHistory(), hIndex = history.length, draft = "";
+ var queue = [], typing = false, typeTimer = null, pendingInterrupt = false, examples = null, tours = [];
+
+ function status(text, isError) {
+ statusEl.textContent = text;
+ statusEl.classList.toggle("osml-repl__status--err", !!isError);
+ }
+ function scroll() { out.scrollTop = out.scrollHeight; }
+ function appendLine(text, cls) {
+ out.appendChild(el("span", cls || lineClass(text) || null, text + "\n"));
+ while (out.childNodes.length > OUTPUT_MAX) out.removeChild(out.firstChild);
+ }
+ function write(text) {
+ var lines = (partial + text).split("\n");
+ partial = lines.pop();
+ lines.forEach(function (l) { appendLine(l); });
+ scroll();
+ }
+ function onWaiting() {
+ if (partial) {
+ promptEl.textContent = partial.replace(/ $/, "\u00a0");
+ partial = "";
+ }
+ rootEl.classList.remove("is-busy");
+ input.disabled = false;
+ if (pendingInterrupt) {
+ pendingInterrupt = false;
+ if (atContinuation()) { interruptREPL(); return; }
+ }
+ pump();
+ }
+ function atContinuation() { return /^\s*\.\.\.>/.test(promptEl.textContent); }
+ function interruptREPL() {
+ rootEl.classList.add("is-busy");
+ session.interrupt();
+ }
+ // interrupt is Ctrl-C: it drops the typed line, any queued walkthrough
+ // input, and the declaration the REPL is still collecting.
+ function interrupt() {
+ clearTimeout(typeTimer);
+ typing = false;
+ queue = [];
+ appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + input.value + "^C", "osml-dim");
+ setInput("");
+ scroll();
+ if (!session || session.exited) return;
+ if (!session.waiting()) pendingInterrupt = true;
+ else if (atContinuation()) interruptREPL();
+ }
+
+ function fetchExamples() {
+ if (!examples) {
+ var list = (d.replExamples || "").split(",").filter(Boolean);
+ examples = Promise.all(list.map(function (name) {
+ return fetch(d.replExampleBase + name).then(function (r) {
+ if (!r.ok) throw new Error(name + " did not download (HTTP " + r.status + ")");
+ return r.text();
+ }).then(function (t) { return [name, t]; });
+ })).then(function (pairs) {
+ var files = {};
+ pairs.forEach(function (p) { files["examples/runtime-showcase/" + p[0]] = p[1]; });
+ return files;
+ });
+ examples.catch(function () { examples = null; });
+ }
+ return examples;
+ }
+
+ function boot() {
+ if (session && !session.exited) return Promise.resolve(session);
+ if (starting) return starting;
+ startBtn.disabled = true;
+ var fresh = !modules[d.replWasm];
+ status(fresh ? "Downloading the sysml REPL\u2026" : "Starting a new session\u2026");
+ starting = loadScript(d.replWasmExec).then(function () {
+ return Promise.all([compile(d.replWasm, function (got, total) {
+ var mb = function (n) { return (n / 1048576).toFixed(1); };
+ status("Downloading the sysml REPL: " + mb(got) + (total ? " of " + mb(total) : "") + " MB\u2026");
+ }), fetchExamples()]);
+ }).then(function (got) {
+ status("Starting the REPL\u2026");
+ var t0 = performance.now();
+ return start({
+ module: got[0],
+ files: got[1],
+ onOutput: write,
+ onWaiting: onWaiting,
+ defer: function (fn) { requestAnimationFrame(function () { setTimeout(fn, 0); }); }
+ }).then(function (s) {
+ session = s;
+ rootEl.classList.add("is-live");
+ startBtn.hidden = true;
+ input.disabled = false;
+ input.placeholder = "SysML or a %command \u2014 \u2191\u2193 history, Tab completes, Shift+Enter adds a line";
+ status("Running the real sysml REPL in this page (started in " +
+ Math.round(performance.now() - t0) + " ms). The runtime-showcase models are in examples/runtime-showcase/.");
+ s.done.then(function () {
+ rootEl.classList.remove("is-live", "is-busy");
+ promptEl.textContent = "$\u00a0";
+ appendLine("[session ended \u2014 press Enter to start a new one]", "osml-dim");
+ scroll();
+ status("The session ended. Press Enter in the prompt, or run a walkthrough step, to start a new one.");
+ queue = [];
+ });
+ return s;
+ });
+ }).catch(function (e) {
+ status("The REPL could not start: " + e.message, true);
+ throw e;
+ }).then(function (s) {
+ starting = null;
+ startBtn.disabled = false;
+ return s;
+ }, function (e) {
+ starting = null;
+ startBtn.disabled = false;
+ throw e;
+ });
+ return starting;
+ }
+
+ function remember(line) {
+ if (line.trim() && history[history.length - 1] !== line) {
+ history.push(line);
+ if (history.length > HISTORY_MAX) history.splice(0, history.length - HISTORY_MAX);
+ saveHistory(history);
+ }
+ hIndex = history.length;
+ draft = "";
+ }
+
+ // submit hands one line to the REPL, echoing it after the prompt it answers.
+ function submit(line) {
+ appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + line, "osml-repl__echo");
+ scroll();
+ rootEl.classList.add("is-busy");
+ session.send(line + "\n");
+ }
+
+ function setInput(text) {
+ input.value = text;
+ input.style.height = "auto";
+ input.style.height = input.scrollHeight + "px";
+ input.style.overflowY = input.scrollHeight > input.clientHeight + 1 ? "auto" : "hidden";
+ }
+ function splitLines(text) { return text.replace(/\r\n?/g, "\n").split("\n"); }
+
+ // pump feeds queued lines one per prompt, typing those from a walkthrough
+ // where motion is allowed.
+ function pump() {
+ if (typing || !queue.length || !session || session.exited || !session.waiting()) return;
+ var next = queue.shift(), line = next.text;
+ if (!next.type || reduceMotion.matches || line.length > 160) {
+ submit(line);
+ return;
+ }
+ typing = true;
+ var i = 0, step = Math.max(1, Math.ceil(line.length / 40));
+ (function type() {
+ i = Math.min(line.length, i + step);
+ setInput(line.slice(0, i));
+ if (i < line.length) { typeTimer = setTimeout(type, 12); return; }
+ typeTimer = setTimeout(function () {
+ typing = false;
+ setInput("");
+ submit(line);
+ }, 120);
+ })();
+ }
+
+ // run sends a block one line per prompt, as a terminal would, and keeps
+ // the block as one history entry.
+ function run(text, type) {
+ remember(text);
+ return boot().then(function () {
+ queue = queue.concat(splitLines(text).map(function (l) { return { text: l, type: type }; }));
+ pump();
+ }, function () {});
+ }
+
+ function complete() {
+ if (!session || session.exited) return;
+ var pos = input.selectionStart, head = input.value.slice(0, pos), tail = input.value.slice(pos);
+ var c = session.complete(head.split("\n").pop());
+ if (!c || !c.candidates || !c.candidates.length) return;
+ var prefix = c.prefix || "", cands = c.candidates;
+ var common = cands.reduce(function (a, b) {
+ var n = 0;
+ while (n < a.length && n < b.length && a[n] === b[n]) n++;
+ return a.slice(0, n);
+ });
+ var base = head.slice(0, head.length - prefix.length);
+ if (cands.length === 1) {
+ var word = cands[0] + (/[/]$/.test(cands[0]) ? "" : " ");
+ setInput(base + word + tail);
+ input.setSelectionRange((base + word).length, (base + word).length);
+ return;
+ }
+ if (common.length > prefix.length) {
+ setInput(base + common + tail);
+ input.setSelectionRange((base + common).length, (base + common).length);
+ return;
+ }
+ appendLine(promptEl.textContent.replace(/\u00a0/g, " ") + input.value, "osml-repl__echo");
+ appendLine(cands.slice(0, 60).join(" ") + (cands.length > 60 ? " \u2026 " + (cands.length - 60) + " more" : ""), "osml-dim");
+ scroll();
+ }
+
+ input.addEventListener("keydown", function (e) {
+ if (e.ctrlKey && e.key === "c" && input.selectionStart === input.selectionEnd) {
+ e.preventDefault();
+ interrupt();
+ return;
+ }
+ if (typing) { e.preventDefault(); return; }
+ var v = input.value;
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ if (!session || session.exited) {
+ boot().then(function () { input.focus(); }, function () {});
+ return;
+ }
+ if (!session.waiting() || queue.length) return;
+ setInput("");
+ run(v, false);
+ } else if ((e.key === "ArrowUp" && v.lastIndexOf("\n", input.selectionStart - 1) < 0) || (e.ctrlKey && e.key === "p")) {
+ e.preventDefault();
+ if (!hIndex) return;
+ if (hIndex === history.length) draft = v;
+ setInput(history[--hIndex]);
+ input.setSelectionRange(input.value.length, input.value.length);
+ } else if ((e.key === "ArrowDown" && v.indexOf("\n", input.selectionEnd) < 0) || (e.ctrlKey && e.key === "n")) {
+ e.preventDefault();
+ if (hIndex >= history.length) return;
+ setInput(++hIndex === history.length ? draft : history[hIndex]);
+ input.setSelectionRange(input.value.length, input.value.length);
+ } else if (e.key === "Tab" && !e.shiftKey && input.value.trim()) {
+ e.preventDefault();
+ complete();
+ } else if (e.ctrlKey && e.key === "l") {
+ e.preventDefault();
+ out.textContent = "";
+ } else if (e.ctrlKey && e.key === "d" && !input.value && session && !session.exited) {
+ e.preventDefault();
+ session.eof();
+ }
+ });
+ input.addEventListener("input", function () { setInput(input.value); });
+ out.addEventListener("click", function () {
+ if (!window.getSelection().toString() && !input.disabled) input.focus();
+ });
+ startBtn.addEventListener("click", function () { boot().then(function () { input.focus(); }, function () {}); });
+
+ // Walkthroughs: each step narrates, shows its input, and types it into the REPL.
+ var tourIndex = 0, stepIndex = 0;
+ function renderTour() {
+ var tour = tours[tourIndex], step = tour.steps[stepIndex];
+ toursEl.querySelectorAll("[data-tour]").forEach(function (b) {
+ b.setAttribute("aria-selected", String(+b.dataset.tour === tourIndex));
+ });
+ var card = toursEl.querySelector("[data-tour-card]");
+ card.innerHTML = "";
+ var head = el("p", "osml-repl__tourhead");
+ head.appendChild(el("span", "osml-repl__tourstep", "Step " + (stepIndex + 1) + " of " + tour.steps.length));
+ head.appendChild(document.createTextNode(" " + step.title));
+ card.appendChild(head);
+ var text = el("div", "osml-repl__tourtext");
+ text.innerHTML = step.text;
+ card.appendChild(text);
+ if (step.input && step.input.length) {
+ var pre = el("pre", "osml-repl__tourinput");
+ pre.appendChild(el("code", null, step.input.join("\n")));
+ card.appendChild(pre);
+ }
+ var nav = el("div", "osml-repl__tournav");
+ var back = el("button", "osml-repl__btn", "\u25c0 Back");
+ back.type = "button";
+ back.disabled = stepIndex === 0;
+ back.addEventListener("click", function () { stepIndex--; renderTour(); });
+ nav.appendChild(back);
+ if (step.input && step.input.length) {
+ var runBtn = el("button", "osml-repl__btn osml-repl__btn--go", "\u25b6 Run this step");
+ runBtn.type = "button";
+ runBtn.addEventListener("click", function () {
+ run(step.input.join("\n"), true);
+ if (stepIndex < tour.steps.length - 1) { stepIndex++; renderTour(); }
+ input.focus({ preventScroll: true });
+ });
+ nav.appendChild(runBtn);
+ }
+ var next = el("button", "osml-repl__btn", stepIndex < tour.steps.length - 1 ? "Skip \u25b6" : "Next walkthrough \u25b6");
+ next.type = "button";
+ next.disabled = stepIndex === tour.steps.length - 1 && tourIndex === tours.length - 1;
+ next.addEventListener("click", function () {
+ if (stepIndex < tour.steps.length - 1) stepIndex++;
+ else { tourIndex++; stepIndex = 0; }
+ renderTour();
+ });
+ nav.appendChild(next);
+ card.appendChild(nav);
+ }
+ if (toursEl && d.replTours) {
+ fetch(d.replTours).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ }).then(function (data) {
+ tours = data.tours;
+ var tabs = toursEl.querySelector("[data-tour-tabs]");
+ tours.forEach(function (t, i) {
+ var b = el("button", "osml-repl__btn", (i + 1) + ". " + t.title);
+ b.type = "button";
+ b.dataset.tour = i;
+ b.setAttribute("role", "tab");
+ b.addEventListener("click", function () { tourIndex = i; stepIndex = 0; renderTour(); });
+ tabs.appendChild(b);
+ });
+ renderTour();
+ }).catch(function (e) {
+ toursEl.querySelector("[data-tour-card]").textContent = "The walkthroughs did not load (" + e.message + ").";
+ });
+ }
+ }
+
+ root.osmlRepl = { start: start, compile: compile, mount: mount, MemFS: MemFS };
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/docs/cli.md b/docs/cli.md
index 70be9470db..87b688d673 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -1,5 +1,5 @@
---
-description: The sysml command line — validator, analysis runner, interactive REPL, behavior engine, RDF exporter and document generator. Try the engine live in your browser.
+description: The sysml command line — validator, analysis runner, interactive REPL, behavior engine, RDF exporter and document generator. Run the real REPL, with guided walkthroughs, in your browser.
---
# The `sysml` command line
@@ -204,66 +204,110 @@ behavior engine, RDF exporter and document generator. The same runtime sits behi
opacity: 1;
border-color: #fff;
}
- .osml-live__editor {
- position: relative;
- border: 1px solid rgba(255, 255, 255, .18);
- border-bottom: none;
- border-radius: .35rem .35rem 0 0;
- background: #10132e;
+ .osml-repl__tours {
+ margin: 0 0 .8rem;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: .35rem;
overflow: hidden;
}
- .osml-live__filename {
- display: block;
- padding: .3rem .7rem;
- font-size: .58rem;
- letter-spacing: .04em;
- color: rgba(232, 234, 246, .55);
- border-bottom: 1px solid rgba(255, 255, 255, .1);
- }
- .osml-live__editor textarea {
- display: block;
- width: 100%;
- min-height: 11rem;
- padding: .6rem .8rem;
+ .osml-repl__tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .2rem;
+ padding: .35rem .45rem;
+ background: var(--md-code-bg-color);
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+ }
+ .osml-repl__btn {
+ padding: .2rem .55rem;
+ border: 1px solid transparent;
+ border-radius: 1rem;
background: transparent;
- border: none;
- outline: none;
- resize: vertical;
- color: #e8eaf6;
- font-family: var(--md-code-font-family, monospace);
- font-size: .6rem;
- line-height: 1.6;
+ color: var(--md-default-fg-color--light);
+ font: inherit;
+ font-size: .62rem;
+ cursor: pointer;
}
- .osml-live .osml-terminal {
- border-radius: 0 0 .35rem .35rem;
+ .osml-repl__btn:hover,
+ .osml-repl__btn:focus-visible { color: var(--md-accent-fg-color); }
+ .osml-repl__btn[aria-selected="true"] {
+ border-color: var(--md-primary-fg-color);
+ color: var(--md-default-fg-color);
+ font-weight: 700;
+ }
+ .osml-repl__btn:disabled { opacity: .4; cursor: default; }
+ .osml-repl__btn--go {
+ background: var(--md-primary-fg-color);
+ color: var(--md-primary-bg-color);
+ font-weight: 700;
+ }
+ .osml-repl__btn--go:hover,
+ .osml-repl__btn--go:focus-visible {
+ background: var(--md-accent-fg-color);
+ color: var(--md-accent-bg-color);
+ }
+ .osml-repl__card { padding: .6rem .8rem .7rem; font-size: .7rem; }
+ .osml-repl__card p { margin: 0 0 .4rem; }
+ .osml-repl__tourhead { font-weight: 700; }
+ .osml-repl__tourstep {
+ margin-right: .3rem;
+ font-weight: 400;
+ color: var(--md-default-fg-color--light);
+ }
+ .osml-repl__tourtext { margin: 0 0 .5rem; }
+ .md-typeset .osml-repl__tourinput {
+ margin: 0 0 .5rem;
+ max-height: 9rem;
+ overflow: auto;
}
- .osml-live__row {
+ .md-typeset .osml-repl__tourinput > code { font-size: .62rem; }
+ .osml-repl__tournav { display: flex; flex-wrap: wrap; gap: .3rem; }
+ .osml-repl .osml-terminal pre {
+ height: 24rem;
+ padding: .7rem .9rem;
+ scrollbar-width: thin;
+ cursor: text;
+ }
+ .osml-repl .osml-terminal pre::-webkit-scrollbar { display: initial; }
+ .osml-repl .osml-repl__echo { color: #fff; }
+ .osml-repl__row {
display: flex;
align-items: center;
gap: .6rem;
- padding: .45rem .7rem;
+ padding: .45rem .9rem;
border-top: 1px solid rgba(255, 255, 255, .12);
background: rgba(255, 255, 255, .05);
- }
- .osml-live__prompt {
- display: flex;
- align-items: center;
- flex: 1;
+ font-family: var(--md-code-font-family, monospace);
font-size: .62rem;
color: #e8eaf6;
}
- .osml-live__prompt input {
+ .osml-repl.is-busy .osml-repl__row { opacity: .6; }
+ .osml-repl__row .osml-repl__btn--go { font-family: var(--md-text-font); }
+ .osml-repl__prompt { display: flex; align-items: flex-start; flex: 1; min-width: 0; line-height: 1.65; }
+ .osml-repl__prompt .osml-prompt { white-space: pre; }
+ .osml-repl__prompt textarea {
flex: 1;
+ min-width: 0;
+ max-height: 14rem;
+ margin: 0;
+ padding: 0;
background: transparent;
border: none;
outline: none;
+ resize: none;
+ overflow: hidden;
color: #e8eaf6;
font: inherit;
- font-family: var(--md-code-font-family, monospace);
+ line-height: inherit;
+ white-space: pre-wrap;
}
- .osml-live__prompt input::placeholder {
- color: rgba(232, 234, 246, .4);
+ .osml-repl__prompt textarea::placeholder { color: rgba(232, 234, 246, .4); }
+ .osml-repl__status {
+ margin: .4rem 0 0;
+ font-size: .62rem;
+ color: var(--md-default-fg-color--light);
}
+ .osml-repl__status--err { color: var(--md-typeset-del-color, #c62828); }
@media (prefers-reduced-motion: reduce) {
.osml-pane { transition: none; }
}
@@ -612,148 +656,59 @@ Vehicles::Car::wheels PartUsage
-
The same engine compiles to WebAssembly — this is a real sysml
-session running in the page, no server involved. Edit the model, %parse it,
-then evaluate, instantiate and execute against it.
This is the real sysml REPL, the same cmd/sysml you run in a
+terminal, built for WebAssembly with the production build tags and running entirely in this
+page. Nothing is sent to a server. Pick a walkthrough and press Run this step to type
+each command into the prompt, or type your own. Arrow keys bring back earlier lines, including
+those from your last visit, and Tab completes commands and names.