diff --git a/.gitignore b/.gitignore index cc239aa..7de9e61 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules dist .next out -.vercel \ No newline at end of file +.vercel +tsconfig.tsbuildinfo diff --git a/app/docs/aynchronous-operations.recho.js b/app/docs/aynchronous-operations.recho.js index 4af3518..39377a6 100644 --- a/app/docs/aynchronous-operations.recho.js +++ b/app/docs/aynchronous-operations.recho.js @@ -55,7 +55,7 @@ echo(string2); * Refer to https://recho.dev/docs/libraries-imports for more details. */ -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array"); //➜ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] echo(d3.range(10)); diff --git a/app/docs/getting-started.recho.js b/app/docs/getting-started.recho.js index d98b049..9a10f66 100644 --- a/app/docs/getting-started.recho.js +++ b/app/docs/getting-started.recho.js @@ -238,10 +238,10 @@ const sum = echo(numbers.reduce((a, b) => a + scale * b, 0)); * the `recho.require` function. The import specifiers must be valid npm * package names with optional version specifiers. * - * For example, let's import the `d3` package: + * For example, let's import the `d3-array` package: */ -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array"); //➜ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] echo(d3.range(10)); diff --git a/app/examples/abacus.recho.js b/app/examples/abacus.recho.js index 142ddb1..2a202a3 100644 --- a/app/examples/abacus.recho.js +++ b/app/examples/abacus.recho.js @@ -131,4 +131,4 @@ class Canvas { } } -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array"); diff --git a/app/examples/animals-isotype-chart.recho.js b/app/examples/animals-isotype-chart.recho.js index 09c4e44..324b1d7 100644 --- a/app/examples/animals-isotype-chart.recho.js +++ b/app/examples/animals-isotype-chart.recho.js @@ -68,17 +68,18 @@ const data = [ /** * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - * Importing D3 + * Importing D3 Array * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * - * Then we import D3 to help us with the data processing. In Recho, you can - * typically use `recho.require(name)` to import an external library. + * Then we import D3's array helpers to help us with the data processing. In + * Recho, you can typically use `recho.require(name)` to import an external + * library. * * > Ref. https://recho.dev/docs/libraries-imports * > Ref. https://d3js.org/ */ -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array"); /** * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/app/examples/fire!.recho.js b/app/examples/fire!.recho.js index 18a0558..5edde46 100644 --- a/app/examples/fire!.recho.js +++ b/app/examples/fire!.recho.js @@ -84,7 +84,7 @@ const frame = recho.interval(1000 / 15); echo(output); } -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array", "d3-random", "d3-scale"); /** * I like this example also because I found one importable noise library: diff --git a/app/examples/matrix-rain.recho.js b/app/examples/matrix-rain.recho.js index 6968985..39cb353 100644 --- a/app/examples/matrix-rain.recho.js +++ b/app/examples/matrix-rain.recho.js @@ -116,4 +116,4 @@ function randomChar() { const frame = recho.interval(1000 / 15); -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array", "d3-random"); diff --git a/app/examples/ml5-handpose.recho.js b/app/examples/ml5-handpose.recho.js index 90357f5..e270fb2 100644 --- a/app/examples/ml5-handpose.recho.js +++ b/app/examples/ml5-handpose.recho.js @@ -121,4 +121,4 @@ function removeCapture(video) { const ml5 = await recho.require("https://unpkg.com/ml5@1/dist/ml5.js"); const p5 = await recho.require("https://unpkg.com/p5@1.2.0/lib/p5.js"); -const d3 = await recho.require("d3"); +const d3 = await recho.require("d3-array", "d3-scale"); diff --git a/app/examples/moon-sundial.recho.js b/app/examples/moon-sundial.recho.js index 228de92..2c3125b 100644 --- a/app/examples/moon-sundial.recho.js +++ b/app/examples/moon-sundial.recho.js @@ -83,7 +83,7 @@ const pos = d3.scaleLinear([0, size], [-1, 1]); echo(output); } -const d3 = recho.require("d3"); +const d3 = recho.require("d3-scale"); /** * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/app/examples/phases-of-the-moon.recho.js b/app/examples/phases-of-the-moon.recho.js index b6ae698..50989ca 100644 --- a/app/examples/phases-of-the-moon.recho.js +++ b/app/examples/phases-of-the-moon.recho.js @@ -88,5 +88,5 @@ function getMoonEmoji(date) { } const suncalc = recho.require("suncalc"); -const d3 = recho.require("d3"); +const d3 = recho.require("d3-time", "d3-time-format"); const _ = recho.require("lodash"); diff --git a/app/examples/random-histogram.recho.js b/app/examples/random-histogram.recho.js index fa14243..0e91b5b 100644 --- a/app/examples/random-histogram.recho.js +++ b/app/examples/random-histogram.recho.js @@ -26,7 +26,7 @@ * results. */ -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array", "d3-random"); const count = 200; const width = 50; diff --git a/app/examples/sorting.recho.js b/app/examples/sorting.recho.js index 1698554..4adef7a 100644 --- a/app/examples/sorting.recho.js +++ b/app/examples/sorting.recho.js @@ -166,4 +166,4 @@ function* sortQuick(array, left = 0, right = array.length - 1) { yield* sortQuick(array, i + 1, right); } -const d3 = recho.require("d3"); +const d3 = recho.require("d3-array", "d3-random", "d3-scale"); diff --git a/editor/blocks/BlockMetadata.ts b/editor/blocks/BlockMetadata.ts index 7208326..937fc06 100644 --- a/editor/blocks/BlockMetadata.ts +++ b/editor/blocks/BlockMetadata.ts @@ -3,8 +3,16 @@ import type {Transaction} from "@codemirror/state"; export type Range = {from: number; to: number}; export class BlockMetadata { + public readonly id: string; + public readonly name: string; + public readonly output: Range | null; + public readonly source: Range; + public attributes: Record; + public error: boolean; + /** * Create a new `BlockMetadata` instance. + * @param id a unique identifier for this block * @param name a descriptive name of this block * @param output the range of the output region * @param source the range of the source region @@ -12,13 +20,20 @@ export class BlockMetadata { * @param error whether this block has an error */ public constructor( - public readonly id: string, - public readonly name: string, - public readonly output: Range | null, - public readonly source: Range, - public attributes: Record = {}, - public error: boolean = false, - ) {} + id: string, + name: string, + output: Range | null, + source: Range, + attributes: Record = {}, + error: boolean = false, + ) { + this.id = id; + this.name = name; + this.output = output; + this.source = source; + this.attributes = attributes; + this.error = error; + } /** * Get the start position (inclusive) of this block. diff --git a/eslint.config.mjs b/eslint.config.mjs index f4a2295..fe9447c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ export default defineConfig([ files: [ "editor/**/*.{js,ts,tsx}", "runtime/**/*.{js,ts}", + "terminal/**/*.ts", "test/**/*.{js,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "lib/**/*.{js,ts}", @@ -44,7 +45,14 @@ export default defineConfig([ // TypeScript-specific configuration ...tseslint.configs.recommended.map((config) => ({ ...config, - files: ["editor/**/*.{ts,tsx}", "runtime/**/*.ts", "test/**/*.{ts,tsx}", "app/**/*.{ts,tsx}", "lib/**/*.ts"], + files: [ + "editor/**/*.{ts,tsx}", + "runtime/**/*.ts", + "terminal/**/*.ts", + "test/**/*.{ts,tsx}", + "app/**/*.{ts,tsx}", + "lib/**/*.ts", + ], })), { ignores: ["**/*.recho.js", "test/output/**/*"], diff --git a/package.json b/package.json index 96882f8..0870ebb 100644 --- a/package.json +++ b/package.json @@ -25,15 +25,20 @@ "computational-art" ], "type": "module", + "bin": { + "recho": "./terminal/cli.ts" + }, "scripts": { "dev": "vite", + "tui": "node terminal/cli.ts", "app:dev": "next dev", "app:build": "next build", "app:start": "next start", - "test": "npm run test:lint && npm run test:format && npm run test:js", + "test": "npm run test:lint && npm run test:format && npm run test:typecheck && npm run test:js", "test:js": "TZ=America/New_York vitest", - "test:format": "prettier --check editor runtime test app", - "test:lint": "eslint" + "test:format": "prettier --check editor runtime terminal test app", + "test:lint": "eslint", + "test:typecheck": "tsc --noEmit" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", @@ -87,19 +92,26 @@ "codemirror": "^6.0.2", "d3-array": "^3.2.4", "d3-dispatch": "^3.0.1", + "d3-random": "^3.0.1", "d3-require": "^1.3.0", + "d3-scale": "^4.0.2", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", "eslint-linter-browserify": "^9.39.2", "friendly-words": "^1.3.1", "jotai": "^2.16.1", + "lodash": "^4.18.1", "nanoid": "^5.1.6", "next": "^15.5.9", "nstr": "^0.1.3", "object-inspect": "^1.13.4", + "perlin-noise-3d": "^0.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", "shiki": "^3.20.0", "short-uuid": "^5.2.0", "source-map-support": "^0.5.21", + "suncalc": "^1.9.0", "table": "^6.9.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2446958..d0f2b56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,21 @@ importers: d3-dispatch: specifier: ^3.0.1 version: 3.0.1 + d3-random: + specifier: ^3.0.1 + version: 3.0.1 d3-require: specifier: ^1.3.0 version: 1.3.0 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 + d3-time: + specifier: ^3.1.0 + version: 3.1.0 + d3-time-format: + specifier: ^4.1.0 + version: 4.1.0 eslint-linter-browserify: specifier: ^9.39.2 version: 9.39.2 @@ -86,6 +98,9 @@ importers: jotai: specifier: ^2.16.1 version: 2.16.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.6)(react@19.2.3) + lodash: + specifier: ^4.18.1 + version: 4.18.1 nanoid: specifier: ^5.1.6 version: 5.1.6 @@ -98,6 +113,9 @@ importers: object-inspect: specifier: ^1.13.4 version: 1.13.4 + perlin-noise-3d: + specifier: ^0.5.4 + version: 0.5.4 react: specifier: ^19.2.3 version: 19.2.3 @@ -113,6 +131,9 @@ importers: source-map-support: specifier: ^0.5.21 version: 0.5.21 + suncalc: + specifier: ^1.9.0 + version: 1.9.0 table: specifier: ^6.9.0 version: 6.9.0 @@ -1732,13 +1753,41 @@ packages: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} engines: {node: '>=12'} + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + d3-require@1.3.0: resolution: {integrity: sha512-XaNc2azaAwXhGjmCMtxlD+AowpMfLimVsAoTMpqrvb8CWoA4QqyV12mc4Ue6KSoDvfuS831tsumfhDYxGd4FGA==} + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -2592,8 +2641,8 @@ packages: lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -2902,6 +2951,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perlin-noise-3d@0.5.4: + resolution: {integrity: sha512-4BbwqQ/e8HjumsaiWSemu9FggqbbX4NeEtLaWvK3554YcnqmK6APtOAAqZ9xT8lFdKCGYgpUPAhaPOHCJuvEyw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3283,6 +3335,9 @@ packages: babel-plugin-macros: optional: true + suncalc@1.9.0: + resolution: {integrity: sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A==} + supertap@3.0.1: resolution: {integrity: sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5319,7 +5374,7 @@ snapshots: esutils: 2.0.3 fast-diff: 1.3.0 js-string-escape: 1.0.1 - lodash: 4.17.21 + lodash: 4.18.1 md5-hex: 3.0.1 semver: 7.7.3 well-known-symbols: 2.0.0 @@ -5371,10 +5426,36 @@ snapshots: dependencies: internmap: 2.0.3 + d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-random@3.0.1: {} + d3-require@1.3.0: {} + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -6351,7 +6432,7 @@ snapshots: lodash.truncate@4.4.2: {} - lodash@4.17.21: {} + lodash@4.18.1: {} loose-envify@1.4.0: dependencies: @@ -6645,6 +6726,8 @@ snapshots: pathval@2.0.1: {} + perlin-noise-3d@0.5.4: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -7128,6 +7211,8 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 + suncalc@1.9.0: {} + supertap@3.0.1: dependencies: indent-string: 5.0.0 diff --git a/runtime/controls/index.js b/runtime/controls/index.js index 8cd5450..2b7085f 100644 --- a/runtime/controls/index.js +++ b/runtime/controls/index.js @@ -1,3 +1,3 @@ -export * from "./toggle"; -export * from "./radio"; -export * from "./number"; +export * from "./toggle.js"; +export * from "./radio.js"; +export * from "./number.js"; diff --git a/runtime/index.js b/runtime/index.js index 1e24999..eb72c9b 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -3,6 +3,7 @@ import {Runtime} from "@observablehq/runtime"; import {parse} from "acorn"; import {group} from "d3-array"; import {dispatch as d3Dispatch} from "d3-dispatch"; +import vm from "node:vm"; import * as stdlib from "./stdlib/index.js"; import {Inspector} from "./stdlib/inspect.js"; import {BlockMetadata} from "../editor/blocks/BlockMetadata.ts"; @@ -16,12 +17,95 @@ function uid() { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); } -function safeEval(code, inputs, __setEcho__) { - const create = (code) => { - // Ensure the current echo function is bound for the executing cell. - const body = `__setEcho__(echo); const __foo__ = ${code}; const v = __foo__(${inputs.join(",")}); __setEcho__(null); return v;`; - const fn = new Function("__setEcho__", ...inputs, body); - return (...args) => fn(__setEcho__, ...args); +const HAS_VM = typeof vm?.runInThisContext === "function"; +// Default cell timeout. vm's timeout option aborts synchronous loops longer +// than this — the most common "accidentally hung the app" bug. It does NOT +// catch async hangs (timers, awaits) — those would need worker_threads — but +// `while(true) {}` and friends are no longer fatal. +const DEFAULT_CELL_TIMEOUT_MS = 1000; + +// We deliberately compile and execute cells in the *current* JS realm +// (`runInThisContext`) instead of a fresh vm context. A fresh context has its +// own primordials, so `{a: 1}` inside it inspects as +// `[Object: null prototype] { ... }` from the host — that breaks the +// existing inspector snapshots and feels foreign in error messages. Using +// the host realm keeps Object/Array/Promise identical to what the rest of +// the runtime uses, while still letting `vm.Script.timeout` interrupt the +// synchronous cell body. This means we don't get *security* sandboxing, but +// the user is running their own notebook, so the goal is liveness, not +// isolation. +const CELL_FN_SLOT = "__rechoCellFn$$"; +const CELL_ARGS_SLOT = "__rechoCellArgs$$"; +const CALL_SCRIPT = HAS_VM + ? new vm.Script(`globalThis.${CELL_FN_SLOT}.apply(undefined, globalThis.${CELL_ARGS_SLOT})`, { + filename: "recho:call.js", + }) + : null; + +function isTimeoutError(error) { + return ( + error && (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || /Script execution timed out/i.test(error?.message || "")) + ); +} + +class CellTimeoutError extends Error { + constructor(timeoutMs) { + super( + `Cell execution exceeded ${timeoutMs}ms — likely an infinite loop. The runtime kept the editor responsive; fix the cell and re-run.`, + ); + this.name = "TimeoutError"; + this.code = "ERR_RECHO_CELL_TIMEOUT"; + this.timeoutMs = timeoutMs; + } +} + +// Compile `code` (a Recho-transpiled cell expression) into a callable that +// the Observable runtime can invoke with input values. When `vm` is +// available, each invocation runs through a tiny pre-compiled wrapper script +// with `timeout` armed, so a synchronous infinite loop inside the cell is +// interrupted instead of freezing the host event loop forever. +function safeEval(code, inputs, __setEcho__, timeoutMs) { + const create = (codeStr) => { + const argList = ["__setEcho__", ...inputs].join(","); + const body = `(function(${argList}){ + __setEcho__(echo); + const __foo__ = ${codeStr}; + const v = __foo__(${inputs.join(",")}); + __setEcho__(null); + return v; + })`; + + let fn; + if (HAS_VM) { + fn = vm.runInThisContext(body, {filename: "recho:cell.js"}); + } else { + fn = new Function("__return__", `return ${body};`)(null); + } + + if (!HAS_VM) { + return (...args) => fn(__setEcho__, ...args); + } + + return (...args) => { + const prevFn = globalThis[CELL_FN_SLOT]; + const prevArgs = globalThis[CELL_ARGS_SLOT]; + globalThis[CELL_FN_SLOT] = fn; + globalThis[CELL_ARGS_SLOT] = [__setEcho__, ...args]; + try { + return CALL_SCRIPT.runInThisContext({ + timeout: timeoutMs, + breakOnSigint: true, + }); + } catch (e) { + if (isTimeoutError(e)) throw new CellTimeoutError(timeoutMs); + throw e; + } finally { + // Restore (rather than delete) so a re-entrant call from a nested + // safeEval — should one ever happen — sees its own slot. + globalThis[CELL_FN_SLOT] = prevFn; + globalThis[CELL_ARGS_SLOT] = prevArgs; + } + }; }; try { return create(code); @@ -35,6 +119,8 @@ function safeEval(code, inputs, __setEcho__) { } } +export {CellTimeoutError}; + function debounce(fn, delay = 0) { let timeout; return (...args) => { @@ -43,10 +129,11 @@ function debounce(fn, delay = 0) { }; } -export function createRuntime(initialCode) { +export function createRuntime(initialCode, options = {}) { let code = initialCode; let prevCode = null; let isRunning = false; + const cellTimeoutMs = options.cellTimeoutMs ?? DEFAULT_CELL_TIMEOUT_MS; // Create button registry for this runtime instance const buttonRegistry = new ButtonRegistry(); @@ -365,7 +452,7 @@ export function createRuntime(initialCode) { ); v._shadow.set("echo", vd); const newInputs = [...inputs, "echo"]; - state.variables.push(v.define(vid, newInputs, safeEval(body, newInputs, __setEcho__))); + state.variables.push(v.define(vid, newInputs, safeEval(body, newInputs, __setEcho__, cellTimeoutMs))); // Export cell-level variables for external access. for (const o of outputs) { diff --git a/runtime/stdlib/index.js b/runtime/stdlib/index.js index c11d150..2af2913 100644 --- a/runtime/stdlib/index.js +++ b/runtime/stdlib/index.js @@ -1,4 +1,41 @@ -export {require} from "d3-require"; +import {require as browserRequire} from "d3-require"; + +const nodeImportCache = new Map(); + +function hasDocument() { + return typeof document !== "undefined" && document.createElement && document.head; +} + +function normalizeModule(module) { + const keys = Object.keys(module); + if (keys.includes("module.exports")) return module.default; + return keys.length === 1 && keys[0] === "default" ? module.default : module; +} + +function mergeModules(modules) { + const merged = {}; + for (const module of modules) { + const normalized = normalizeModule(module); + Object.assign(merged, normalized); + } + return merged; +} + +function nodeImport(name) { + if (typeof name !== "string") return Promise.resolve(name); + let module = nodeImportCache.get(name); + if (!module) { + module = import(name).then(normalizeModule); + nodeImportCache.set(name, module); + } + return module; +} + +export function require(...names) { + if (hasDocument()) return browserRequire(...names); + return names.length > 1 ? Promise.all(names.map(nodeImport)).then(mergeModules) : nodeImport(names[0]); +} + export {now} from "./now.js"; export {interval} from "./interval.js"; export {inspect, Inspector} from "./inspect.js"; diff --git a/runtime/worker.ts b/runtime/worker.ts new file mode 100644 index 0000000..13b28a7 --- /dev/null +++ b/runtime/worker.ts @@ -0,0 +1,195 @@ +// Runtime worker: boots the Recho/Observable runtime in its own thread so +// the TUI in the main thread keeps painting and reading input even when a +// notebook does something pathological (an async tight loop, a recursive +// promise chain, or a long blocking call that vm.timeout can't catch on its +// own). +// +// The protocol is small. Main thread sends: +// {type: "init", code, options?} — boot a fresh runtime +// {type: "setCode", code} — keep runtime's view in sync +// {type: "setIsRunning", value} — pause/resume the runtime +// {type: "run"} — re-run from scratch +// {type: "destroy"} — drop the runtime +// The worker sends back: +// {type: "ready"} — initial run completed +// {type: "changes", changes: [...]} — buffer change-spec array +// {type: "error", error, source} — parse / split errors +// {type: "console", level, text} — captured console.error etc. +// {type: "heartbeat", t} — every 200ms; main uses these +// to detect a hung worker +// +// Effects are intentionally dropped from "changes" — they hold CodeMirror +// state-effect objects that don't structured-clone. + +import {parentPort} from "node:worker_threads"; + +if (!parentPort) { + throw new Error("runtime/worker.ts must be loaded as a worker_thread."); +} + +const port = parentPort; + +type WorkerPostMessage = + | {type: "ready"} + | {type: "changes"; changes: unknown[]} + | {type: "error"; error: SerializedError; source: string} + | {type: "console"; level: ConsoleLevel; text: string} + | {type: "heartbeat"; t: number} + | {type: "online"}; + +type WorkerCommand = + | {type: "init"; code: string; options?: RuntimeOptions} + | {type: "setCode"; code: string} + | {type: "setIsRunning"; value: boolean} + | {type: "run"} + | {type: "destroy"}; + +type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug"; +type RuntimeOptions = {cellTimeoutMs?: number}; +type SerializedError = {message: string; name: string; stack: string | null; code: unknown}; +type RuntimeInstance = { + destroy?: () => void; + onChanges: (callback: (event: {changes: unknown[]}) => void) => void; + onError: (callback: (event: {error: unknown; source?: string}) => void) => void; + setIsRunning: (value: boolean) => void; + setCode: (code: string) => void; + run: () => void; +}; + +// -- Capture console & stderr BEFORE loading the runtime, so anything the +// stdlib (or its observer.rejected path) writes during boot is forwarded. +function safePost(msg: WorkerPostMessage) { + try { + port.postMessage(msg); + } catch { + /* parent gone */ + } +} + +function stringifyArgs(args: unknown[]): string { + return args + .map((a) => { + if (a instanceof Error) return (a.stack ? a.stack : a.message) || String(a); + if (typeof a === "string") return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +function serializeError(e: unknown): SerializedError { + if (!e) return {message: "(unknown error)", name: "Error", stack: null, code: null}; + const error = e as {message?: string; name?: string; stack?: string; code?: unknown}; + return { + message: error.message || String(e), + name: error.name || "Error", + stack: error.stack || null, + code: error.code || null, + }; +} + +for (const level of ["log", "info", "warn", "error", "debug"] satisfies ConsoleLevel[]) { + console[level] = (...args) => safePost({type: "console", level, text: stringifyArgs(args)}); +} + +const origStderrWrite = process.stderr.write.bind(process.stderr); +process.stderr.write = (( + chunk: string | Uint8Array, + encoding?: BufferEncoding | ((err?: Error | null) => void), + callback?: (err?: Error | null) => void, +) => { + const text = typeof chunk === "string" ? chunk : (chunk?.toString?.() ?? String(chunk)); + if (text.trim()) safePost({type: "console", level: "error", text: text.replace(/\n+$/, "")}); + if (typeof encoding === "function") encoding(); + else if (typeof callback === "function") callback(); + return true; +}) as typeof process.stderr.write; + +// Process-level catch-alls — async failures in notebook code shouldn't kill +// the worker (and even if they do, the main thread will just respawn). +process.on("unhandledRejection", (e) => { + safePost({type: "console", level: "error", text: `[unhandledRejection] ${stringifyArgs([e])}`}); +}); +process.on("uncaughtException", (e) => { + safePost({type: "console", level: "error", text: `[uncaughtException] ${stringifyArgs([e])}`}); +}); + +// Heartbeat: as long as the worker's event loop turns, the main thread sees +// a tick. A blocked sync loop short-circuits this — vm.timeout typically +// kicks in first, but if it doesn't, the main-thread watchdog notices the +// gap and terminates the worker. +const HEARTBEAT_MS = 200; +setInterval(() => safePost({type: "heartbeat", t: Date.now()}), HEARTBEAT_MS).unref(); + +let rt: RuntimeInstance | null = null; + +function bootRuntime(code: string, options?: RuntimeOptions) { + rt?.destroy?.(); + rt = null; + + // Lazy import so the heartbeat above is already running when the runtime + // (and its dependencies) get evaluated. + return import("./index.js").then(({createRuntime}) => { + rt = createRuntime(code, options || {}) as RuntimeInstance; + rt.onChanges((evt) => safePost({type: "changes", changes: evt.changes})); + rt.onError((evt) => safePost({type: "error", error: serializeError(evt.error), source: evt.source || "runtime"})); + rt.setIsRunning(true); + try { + rt.run(); + } catch (e) { + safePost({type: "console", level: "error", text: `[run] ${stringifyArgs([e])}`}); + } + safePost({type: "ready"}); + }); +} + +port.on("message", (msg: WorkerCommand) => { + switch (msg?.type) { + case "init": + bootRuntime(msg.code, msg.options).catch((e) => + safePost({type: "console", level: "error", text: `[init] ${stringifyArgs([e])}`}), + ); + break; + case "setCode": + try { + rt?.setCode(msg.code); + } catch (e) { + safePost({type: "console", level: "error", text: `[setCode] ${stringifyArgs([e])}`}); + } + break; + case "setIsRunning": + try { + rt?.setIsRunning(msg.value); + } catch (e) { + safePost({type: "console", level: "error", text: `[setIsRunning] ${stringifyArgs([e])}`}); + } + break; + case "run": + try { + rt?.run(); + } catch (e) { + safePost({type: "console", level: "error", text: `[run] ${stringifyArgs([e])}`}); + } + break; + case "destroy": + try { + rt?.destroy?.(); + } catch { + /* ignore */ + } + rt = null; + break; + default: + // Unknown message — log to original stderr (which we replaced above). + try { + origStderrWrite(`recho-worker: unknown message type ${JSON.stringify((msg as {type?: unknown}).type)}\n`); + } catch { + /* ignore */ + } + } +}); + +safePost({type: "online"}); diff --git a/terminal/app.ts b/terminal/app.ts new file mode 100644 index 0000000..efbcaf7 --- /dev/null +++ b/terminal/app.ts @@ -0,0 +1,1707 @@ +// Terminal Recho Notebook — TUI editor that runs the same notebook runtime +// used by the web app and echoes output as inline `//➜` comments above each +// expression. Mouse-aware (click to focus, drag to select, wheel to scroll). + +import fs from "node:fs"; +import path from "node:path"; +import {Buffer as NodeBuffer} from "node:buffer"; +import {createWorkerRuntime} from "./workerRuntime.ts"; +import type {WorkerRuntime} from "./workerRuntime.ts"; +import {Buffer as DocumentBuffer} from "./buffer.ts"; +import {highlightLine, nextHighlightState, COLORS} from "./highlight.ts"; +import {loadHelpDocs} from "./docs.ts"; +import type {HelpDocs} from "./docs.ts"; +import * as scr from "./screen.ts"; +import {fg, bg, reset, bold, padToWidth, visibleLength} from "./screen.ts"; + +const HEADER_ROWS = 2; +const FOOTER_ROWS = 2; +const GUTTER = 5; // " 123 " + +type RunState = "idle" | "running" | "success" | "error" | "timeout"; +type ConsoleEntry = {ts: number; level: string; text: string; count: number}; +type ConsoleSavedHandlers = { + error: typeof console.error; + warn: typeof console.warn; + log: typeof console.log; + info: typeof console.info; + stderrWrite: typeof process.stderr.write; +}; +type AppOptions = { + initialPath: string | null; + initialCode: string; + examplesDir: string | null; + docsDir?: string | null; +}; +type Box = {top: number; left: number; width: number; height: number; bottom?: number; right?: number}; +type EditorBox = {top: number; bottom: number; left: number; right: number; width: number; height: number}; +type ScrollbarState = { + trackTop: number; + trackBottom: number; + trackHeight: number; + thumbTop: number; + thumbBottom: number; + thumbHeight: number; + travel: number; + maxScroll: number; +}; +type ModalBag = { + type: string; + entries: string[]; + index: number; + query: string; + scroll: number; + title: string; + value: string; + onSubmit: (value: string) => void; + help: HelpDocs; + helpIndex: number; + helpSlug: string; + helpScroll: number; + helpOutlineScroll: number; +}; +type ModalState = (Partial & {type: string}) | null; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function errorStackOrMessage(error: unknown): string { + return error instanceof Error ? error.stack || error.message : String(error); +} + +export class App { + path: string | null; + examplesDir: string | null; + docsDir: string | null; + helpDocs: HelpDocs | null; + buffer: DocumentBuffer; + scrollY: number; + scrollX: number; + cols: number; + rows: number; + runtime: WorkerRuntime | null; + runError: Error | null; + message: string | null; + messageUntil: number; + dirty: boolean; + prevGrid: scr.Grid | null; + grid: scr.Grid; + cursorVisible: boolean; + mouseSelecting: boolean; + scrollbarDragging: boolean; + scrollbarDragOffset: number; + modal: ModalState; + runState: RunState; + runStartTs: number; + runErrorCountAtStart: number; + lastChangeTs: number; + runFinishTs: number; + spinnerFrame: number; + editorRowMap: Map; + statusFlash: string | null; + console: ConsoleEntry[]; + consoleSeen: number; + consoleSavedHandlers: ConsoleSavedHandlers | null; + cellTimeoutMs: number; + tickInterval?: NodeJS.Timeout; + cursorBlinkOn?: boolean; + lastBlinkTs?: number; + + constructor({initialPath, initialCode, examplesDir, docsDir = null}: AppOptions) { + this.path = initialPath; + this.examplesDir = examplesDir; + this.docsDir = docsDir; + this.helpDocs = null; + this.buffer = new DocumentBuffer(initialCode); + this.scrollY = 0; + this.scrollX = 0; + this.cols = process.stdout.columns || 100; + this.rows = process.stdout.rows || 30; + this.runtime = null; + this.runError = null; + this.message = null; + this.messageUntil = 0; + this.dirty = true; + this.prevGrid = null; + this.grid = new scr.Grid(this.rows, this.cols); + this.cursorVisible = true; + this.mouseSelecting = false; + this.scrollbarDragging = false; + this.scrollbarDragOffset = 0; + this.modal = null; // null | {type, ...} + // Run-state machine — drives the title-bar dot and the status text. + // idle : ready, no run in flight (initial / after edits) + // running : a run is in progress (sync execution may be blocking) + // success : last run finished without errors + // error : last run produced one or more errors (echoed as //✗) + // timeout : last run hit the cell timeout (likely an infinite loop) + this.runState = "idle"; + this.runStartTs = 0; + this.runErrorCountAtStart = 0; + this.lastChangeTs = 0; + this.runFinishTs = 0; + this.spinnerFrame = 0; + this.editorRowMap = new Map(); // screen row -> {pos, line} + this.statusFlash = null; + // Captured stderr/console output (notebook code, the runtime, or async + // rejections — all things that would otherwise scribble over the screen). + this.console = []; // {ts, level, text, count} + this.consoleSeen = 0; // index up to which the user has dismissed + this.consoleSavedHandlers = null; + // Cell timeout for vm.Script — long-running synchronous loops are + // aborted after this many ms instead of freezing the TUI forever. + this.cellTimeoutMs = 1000; + } + + get activeModal(): ModalBag { + return this.modal as ModalBag; + } + + start() { + process.stdout.write(scr.enterAlt + scr.hideCursor + scr.enableMouse + scr.clearScreen + scr.home); + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + // Redirect stray writes BEFORE booting the runtime — Observable's + // variable rejections hit `console.error` synchronously and would + // otherwise corrupt the alt-screen. + this.captureConsole(); + this.installProcessHandlers(); + + this.initRuntime(); + + process.stdin.on("data", (chunk) => this.onInput(String(chunk))); + process.stdout.on("resize", () => this.onResize()); + + this.flash("Press ^S to run · ^E for examples · ^Q to quit", 4000); + this.loop(); + } + + installProcessHandlers() { + const onSig = () => this.quit(); + process.on("SIGINT", onSig); + process.on("SIGTERM", onSig); + // Don't tear the UI down on async failures from notebook code — capture + // them into the message log and keep going. Truly fatal TUI bugs surface + // as synchronous throws inside our render path, which we let crash. + process.on("uncaughtException", (e) => this.onAsyncError(e, "uncaughtException")); + process.on("unhandledRejection", (e) => this.onAsyncError(e, "unhandledRejection")); + } + + captureConsole() { + if (this.consoleSavedHandlers) return; + this.consoleSavedHandlers = { + error: console.error, + warn: console.warn, + log: console.log, + info: console.info, + stderrWrite: process.stderr.write.bind(process.stderr), + }; + const push = (level: string, args: unknown[]) => { + const text = args + .map((a) => { + if (a instanceof Error) return a.message + (a.stack ? "\n" + a.stack : ""); + if (typeof a === "string") return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); + this.pushConsole(level, text); + }; + console.error = (...args) => push("error", args); + console.warn = (...args) => push("warn", args); + console.log = (...args) => push("log", args); + console.info = (...args) => push("log", args); + // Some libraries write directly to stderr; swallow those too while the + // alt-screen is up so they don't tear the layout. + process.stderr.write = ((chunk: string | Uint8Array) => { + const text = + typeof chunk === "string" ? chunk : NodeBuffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.trim()) this.pushConsole("error", text.replace(/\n+$/, "")); + return true; + }) as typeof process.stderr.write; + } + + restoreConsole() { + if (!this.consoleSavedHandlers) return; + console.error = this.consoleSavedHandlers.error; + console.warn = this.consoleSavedHandlers.warn; + console.log = this.consoleSavedHandlers.log; + console.info = this.consoleSavedHandlers.info; + process.stderr.write = this.consoleSavedHandlers.stderrWrite; + this.consoleSavedHandlers = null; + } + + pushConsole(level: string, text: string) { + // De-duplicate consecutive identical messages — the runtime can fire the + // same rejection many times while a generator re-runs. We bump a count on + // the previous entry instead of flooding the log. + const last = this.console[this.console.length - 1]; + if (last && last.level === level && last.text === text) { + last.count = (last.count || 1) + 1; + last.ts = Date.now(); + } else { + this.console.push({ts: Date.now(), level, text, count: 1}); + if (this.console.length > 500) this.console.splice(0, this.console.length - 500); + } + if (level === "error") { + const summary = text.split("\n")[0].slice(0, 200); + this.flash("✗ " + summary, 4000); + } + this.dirty = true; + } + + unseenErrorCount(): number { + let n = 0; + for (let i = this.consoleSeen; i < this.console.length; i++) { + if (this.console[i].level === "error") n++; + } + return n; + } + + onAsyncError(error: unknown, source: string) { + const msg = errorStackOrMessage(error); + this.pushConsole("error", `[${source}] ${msg}`); + } + + loop() { + const tick = () => { + const now = Date.now(); + if (this.message && now > this.messageUntil) { + this.message = null; + this.dirty = true; + } + // While running, keep advancing the spinner so the user sees motion. + if (this.runState === "running") { + this.spinnerFrame = (this.spinnerFrame + 1) % 10; + this.dirty = true; + } + // Settle "running" → "success" / "error" / "timeout" once the runtime + // has been quiet for a moment. The Observable runtime doesn't emit a + // "done" event, so we wait for a short window with no new changes + // and no new errors. + if (this.runState === "running") { + const SETTLE_MS = 500; + const sinceStart = now - this.runStartTs; + const sinceChange = now - (this.lastChangeTs || this.runStartTs); + const lastErr = this.lastErrorAfter(this.runErrorCountAtStart); + const sinceError = lastErr ? now - lastErr.ts : Infinity; + if (sinceStart > SETTLE_MS && sinceChange > SETTLE_MS && sinceError > SETTLE_MS) { + const seenErrors = this.console.length - this.runErrorCountAtStart > 0; + this.runState = seenErrors ? (this.lastErrorIsTimeout() ? "timeout" : "error") : "success"; + this.runFinishTs = now; + this.dirty = true; + } + } + if (this.dirty) this.render(); + }; + this.tickInterval = setInterval(tick, 80); + } + + lastErrorAfter(fromIndex: number): ConsoleEntry | null { + for (let i = this.console.length - 1; i >= fromIndex; i--) { + const m = this.console[i]; + if (m.level === "error") return m; + } + return null; + } + + lastErrorIsTimeout(): boolean { + const m = this.lastErrorAfter(this.runErrorCountAtStart); + if (!m) return false; + return /TimeoutError|exceeded \d+ms|infinite loop|ERR_RECHO_CELL_TIMEOUT|Script execution timed out/i.test(m.text); + } + + flash(msg: string, ms = 2500) { + this.message = msg; + this.messageUntil = Date.now() + ms; + this.dirty = true; + } + + onFatal(e: unknown) { + this.cleanup(); + console.error("Fatal:", e); + process.exit(1); + } + + quit() { + this.cleanup(); + process.exit(0); + } + + cleanup() { + try { + if (this.tickInterval) clearInterval(this.tickInterval); + this.runtime?.destroy?.(); + process.stdin.setRawMode?.(false); + process.stdout.write(scr.disableMouse + scr.showCursor + scr.leaveAlt + reset); + this.restoreConsole(); + // Replay anything that was captured while the alt-screen was up so the + // user isn't left wondering what happened (only show errors to avoid + // flooding the regular terminal with debug noise). + const errors = this.console.filter((m) => m.level === "error"); + if (errors.length) { + for (const e of errors.slice(-10)) { + process.stderr.write(`recho: ${e.text.split("\n")[0]}\n`); + } + } + } catch { + /* ignore */ + } + } + + // ------------------------------------------------------------------------- + // Runtime — runs in a worker_thread so an async hang in notebook code + // can't freeze the TUI. The watchdog will respawn a worker that's been + // unresponsive for `heartbeatGraceMs` (default 3s). + initRuntime() { + this.runtime?.destroy?.(); + const runtime = createWorkerRuntime(this.buffer.text, { + cellTimeoutMs: this.cellTimeoutMs, + heartbeatGraceMs: 3000, + }); + this.runtime = runtime; + runtime.onChanges(({changes}) => { + if (!changes || !changes.length) return; + this.buffer.applyChanges(changes); + runtime.setCode(this.buffer.text); + this.lastChangeTs = Date.now(); + this.dirty = true; + }); + runtime.onError(({error, source}) => { + // Parse / split errors. They also land in the buffer as `//✗` lines; + // we still want a status-bar nudge. + this.runState = "error"; + this.pushConsole("error", `[${source || "runtime"}] ${errorMessage(error)}`); + this.dirty = true; + }); + runtime.onConsole(({level, text}) => { + // Forwarded console output from the worker thread (notebook code, + // d3-require failures, observer.rejected, …). + this.pushConsole(level, text); + }); + runtime.onHung(({sinceHeartbeatMs}) => { + // The worker has been silent past the grace window. Most likely an + // async tight loop that vm.timeout couldn't catch. Terminate it, + // mark the state, and respawn fresh against the current buffer. + this.pushConsole("error", `[watchdog] runtime unresponsive for ${sinceHeartbeatMs}ms — restarting worker thread`); + this.runState = "timeout"; + this.dirty = true; + try { + runtime.restart(this.buffer.text); + } catch (e) { + this.pushConsole("error", "[watchdog] restart failed: " + errorMessage(e)); + } + }); + runtime.onExit?.(({code}) => { + if (code !== 0 && code != null) { + this.pushConsole("error", `[worker] exited with code ${code}`); + } + }); + this.markRunStart(); + runtime.setIsRunning(true); + try { + runtime.run(); + } catch (e) { + this.runState = "error"; + this.pushConsole("error", "[run] " + errorMessage(e)); + } + } + + markRunStart() { + this.runState = "running"; + this.runStartTs = Date.now(); + this.runErrorCountAtStart = this.console.length; + this.spinnerFrame = 0; + this.dirty = true; + } + + runNow() { + if (!this.runtime) return this.initRuntime(); + this.markRunStart(); + this.runtime.setIsRunning(true); + try { + this.runtime.setCode(this.buffer.text); + if (this.runtime.isAlive && !this.runtime.isAlive()) { + this.runtime.restart(this.buffer.text); + this.flash("Runtime restarted", 1200); + return; + } + this.runtime.run(); + this.flash("Running…", 800); + } catch (e) { + this.runState = "error"; + this.pushConsole("error", "[run] " + errorMessage(e)); + } + this.dirty = true; + } + + stopNow() { + // Hard stop: terminate the worker. ^R / next ^S spawns a fresh one. + // (Soft pause via setIsRunning(false) doesn't help when notebook code + // has already wedged itself.) + if (this.runtime?.terminate) { + this.runtime.terminate(); + this.flash("Stopped — worker terminated; ^S to run", 2500); + } else { + this.runtime?.setIsRunning(false); + this.flash("Stopped", 1500); + } + this.runState = "idle"; + this.dirty = true; + } + + // ------------------------------------------------------------------------- + // Layout + editorBox(): EditorBox { + const top = HEADER_ROWS; + const bottom = this.rows - FOOTER_ROWS; + const left = 0; + const right = Math.max(left, this.cols - 1); + return {top, bottom, left, right, width: right - left, height: bottom - top}; + } + + visibleEditorRows(): number { + const box = this.editorBox(); + return box.height; + } + + // ------------------------------------------------------------------------- + // Input + onInput(chunk: string) { + if (this.modal) return this.onModalInput(chunk); + const events = scr.parseInput(chunk); + for (const ev of events) this.handleEvent(ev); + } + + handleEvent(ev: scr.InputEvent) { + if (ev.type === "text") { + this.userEdit(() => this.buffer.insertAtCursor(ev.text)); + return; + } + if (ev.type === "key") return this.handleKey(ev); + if (ev.type === "mouse") return this.handleMouse(ev); + } + + userEdit(fn: () => void) { + this.runtime?.setIsRunning(false); + this.runState = "idle"; + fn(); + this.runtime?.setCode(this.buffer.text); + this.ensureCursorVisible(); + this.dirty = true; + } + + handleKey(ev: scr.KeyEvent) { + const {name, ctrl, alt, shift} = ev; + // Quit / save / run shortcuts. + if (ctrl && (name === "q" || name === "c")) return this.quit(); + if (ctrl && name === "s") return this.runNow(); + if (ctrl && name === "x") return this.stopNow(); + if (ctrl && name === "r") { + this.initRuntime(); + this.flash("Runtime restarted", 1500); + return; + } + if (ctrl && name === "e") return this.openExamples(); + if (ctrl && name === "n") return this.newFile(); + if (ctrl && name === "o") return this.openFilePrompt(); + if (ctrl && name === "w") return this.savePrompt(); + if (ctrl && name === "t") return this.renamePrompt(); + if (ctrl && name === "l") return this.openConsole(); + if (ctrl && name === "k") return this.openHelp(); + if (ctrl && name === "a") { + this.buffer.moveTo(0); + this.buffer.moveTo(this.buffer.length, true); + this.dirty = true; + return; + } + if (ctrl && name === "home") { + this.buffer.moveTo(0, shift); + this.ensureCursorVisible(); + this.dirty = true; + return; + } + if (ctrl && name === "end") { + this.buffer.moveTo(this.buffer.length, shift); + this.ensureCursorVisible(); + this.dirty = true; + return; + } + + switch (name) { + case "left": + if (alt) this.buffer.wordLeft(shift); + else this.buffer.moveLeft(shift); + break; + case "right": + if (alt) this.buffer.wordRight(shift); + else this.buffer.moveRight(shift); + break; + case "up": + this.buffer.moveUp(shift); + break; + case "down": + this.buffer.moveDown(shift); + break; + case "home": + this.buffer.moveHome(shift); + break; + case "end": + this.buffer.moveEnd(shift); + break; + case "pageup": + for (let i = 0; i < this.visibleEditorRows() - 2; i++) this.buffer.moveUp(shift); + break; + case "pagedown": + for (let i = 0; i < this.visibleEditorRows() - 2; i++) this.buffer.moveDown(shift); + break; + case "backspace": + this.userEdit(() => this.buffer.backspace()); + return; + case "delete": + this.userEdit(() => this.buffer.del()); + return; + case "enter": { + // Auto-indent: copy leading whitespace of current line. + const {row} = this.buffer.posToRowCol(this.buffer.cursor); + const line = this.buffer.lineText(row); + const indent = line.match(/^[\t ]*/)?.[0] ?? ""; + const trimmed = line.slice(0, this.buffer.cursor - this.buffer.lineStarts[row]).trimEnd(); + const extra = /[{[(]\s*$/.test(trimmed) ? " " : ""; + this.userEdit(() => this.buffer.insertAtCursor("\n" + indent + extra)); + return; + } + case "tab": + this.userEdit(() => this.buffer.insertAtCursor(" ")); + return; + case "escape": + this.buffer.anchor = null; + break; + case "space": + this.userEdit(() => this.buffer.insertAtCursor(" ")); + return; + } + this.ensureCursorVisible(); + this.dirty = true; + } + + handleMouse(ev: scr.MouseEvent) { + const box = this.editorBox(); + const {row, col, kind, button, shift} = ev; + if (this.handleScrollbarMouse(ev, box)) return; + if (kind === "wheel-up") { + this.scrollBy(-3); + return; + } + if (kind === "wheel-down") { + this.scrollBy(3); + return; + } + if (row >= box.top && row < box.bottom && col >= box.left && col < box.right) { + const mapped = this.editorRowMap.get(row); + if (!mapped) return; + const sourceRow = mapped.line; + const lineStart = this.buffer.lineStarts[sourceRow] ?? 0; + const lineEnd = this.buffer.lineRange(sourceRow).end; + const targetCol = Math.max(0, col - GUTTER + this.scrollX); + const targetPos = Math.min(lineStart + targetCol, lineEnd); + if (kind === "press" && button === 0) { + this.buffer.moveTo(targetPos, shift); + this.mouseSelecting = true; + } else if (kind === "drag" && this.mouseSelecting) { + this.buffer.moveTo(targetPos, true); + } else if (kind === "release") { + this.mouseSelecting = false; + } + this.cursorBlinkOn = true; + this.lastBlinkTs = Date.now(); + this.dirty = true; + return; + } + // Click on title bar's "Run" hot zone + if (row === 0 && kind === "press" && button === 0) { + const hotStart = this.cols - 12; + if (col >= hotStart) this.runNow(); + } + } + + handleScrollbarMouse(ev: scr.MouseEvent, box = this.editorBox()): boolean { + const scrollbarCol = this.scrollbarCol(); + if (scrollbarCol < 0) return false; + const {row, col, kind, button} = ev; + if (kind === "release") { + if (!this.scrollbarDragging) return false; + this.scrollbarDragging = false; + this.dirty = true; + return true; + } + if (this.scrollbarDragging && kind === "drag") { + this.scrollToScrollbarRow(row - this.scrollbarDragOffset); + return true; + } + if (col !== scrollbarCol || row < box.top || row >= box.bottom) return false; + if (kind === "press" && button === 0) { + const state = this.scrollbarState(box); + if (!state) return true; + if (row >= state.thumbTop && row < state.thumbBottom) { + this.scrollbarDragOffset = row - state.thumbTop; + } else { + this.scrollbarDragOffset = Math.floor(state.thumbHeight / 2); + this.scrollToScrollbarRow(row - this.scrollbarDragOffset, box); + } + this.scrollbarDragging = true; + this.mouseSelecting = false; + this.dirty = true; + return true; + } + return kind === "drag" && col === scrollbarCol; + } + + scrollBy(dy: number) { + this.scrollY = this.clampScrollY(this.scrollY + dy); + this.dirty = true; + } + + maxScrollY(): number { + return Math.max(0, this.buffer.lineCount - this.visibleEditorRows()); + } + + clampScrollY(value: number): number { + return Math.max(0, Math.min(this.maxScrollY(), value)); + } + + scrollbarCol(): number { + return this.cols > 0 ? this.cols - 1 : -1; + } + + scrollbarState(box = this.editorBox()): ScrollbarState | null { + const trackHeight = box.height; + const totalLines = this.buffer.lineCount; + if (trackHeight <= 0 || totalLines <= 0) return null; + const visibleLines = Math.min(trackHeight, totalLines); + const maxScroll = this.maxScrollY(); + const thumbHeight = + maxScroll === 0 ? trackHeight : Math.max(1, Math.floor((visibleLines / totalLines) * trackHeight)); + const travel = trackHeight - thumbHeight; + const thumbOffset = maxScroll === 0 ? 0 : Math.round((this.clampScrollY(this.scrollY) / maxScroll) * travel); + const thumbTop = box.top + thumbOffset; + return { + trackTop: box.top, + trackBottom: box.bottom, + trackHeight, + thumbTop, + thumbBottom: thumbTop + thumbHeight, + thumbHeight, + travel, + maxScroll, + }; + } + + scrollToScrollbarRow(thumbTopRow: number, box = this.editorBox()) { + const state = this.scrollbarState(box); + if (!state || state.maxScroll === 0) { + this.scrollY = 0; + this.dirty = true; + return; + } + const offset = Math.max(0, Math.min(state.travel, thumbTopRow - box.top)); + this.scrollY = Math.round((offset / state.travel) * state.maxScroll); + this.dirty = true; + } + + ensureCursorVisible() { + const {row} = this.buffer.posToRowCol(this.buffer.cursor); + const h = this.visibleEditorRows(); + if (row < this.scrollY) this.scrollY = row; + else if (row >= this.scrollY + h) this.scrollY = row - h + 1; + this.scrollY = this.clampScrollY(this.scrollY); + // Horizontal scroll + const {col} = this.buffer.posToRowCol(this.buffer.cursor); + const w = this.editorBox().width - GUTTER; + if (col < this.scrollX) this.scrollX = col; + else if (col >= this.scrollX + w) this.scrollX = col - w + 1; + } + + onResize() { + this.cols = process.stdout.columns || this.cols; + this.rows = process.stdout.rows || this.rows; + this.scrollY = this.clampScrollY(this.scrollY); + this.scrollbarDragging = false; + this.grid = new scr.Grid(this.rows, this.cols); + this.prevGrid = null; + process.stdout.write(scr.clearScreen); + this.dirty = true; + } + + // ------------------------------------------------------------------------- + // Render + render() { + this.dirty = false; + this.scrollY = this.clampScrollY(this.scrollY); + this.grid.clear(); + this.editorRowMap.clear(); + this.drawHeader(); + this.drawEditor(); + this.drawScrollbar(); + this.drawFooter(); + if (this.modal) this.drawModal(); + scr.renderDiff(this.prevGrid, this.grid, (s) => process.stdout.write(s)); + this.prevGrid = cloneGrid(this.grid); + + // Real terminal cursor positioned at the editing caret (only when no modal). + if (!this.modal) { + const {row, col} = this.buffer.posToRowCol(this.buffer.cursor); + const screenRow = HEADER_ROWS + (row - this.scrollY); + const screenCol = GUTTER + (col - this.scrollX); + const box = this.editorBox(); + if (screenRow >= box.top && screenRow < box.bottom && screenCol >= GUTTER && screenCol < box.right) { + process.stdout.write(scr.moveTo(screenRow, screenCol) + scr.showCursor); + } else { + process.stdout.write(scr.hideCursor); + } + } else { + process.stdout.write(scr.hideCursor); + } + } + + drawHeader() { + const title = " Recho · " + (this.path ? path.basename(this.path) : "untitled.recho.js"); + const headerStyle = bg(234) + fg(COLORS.title); + this.grid.fillRect(0, 0, 1, this.cols, " ", headerStyle); + this.grid.writeStyled(0, 0, headerStyle + bold + title + reset, headerStyle); + + // Status indicator: spinner + colored dot + label. + const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const state = this.runState; + let dot, label, indicatorColor; + if (state === "running") { + indicatorColor = COLORS.hot; + dot = fg(indicatorColor) + bold + SPINNER[this.spinnerFrame % SPINNER.length] + reset; + const elapsedMs = Date.now() - this.runStartTs; + label = `running ${(elapsedMs / 1000).toFixed(1)}s`; + } else if (state === "success") { + indicatorColor = COLORS.output; + dot = fg(indicatorColor) + "●" + reset; + label = "success"; + } else if (state === "error") { + indicatorColor = COLORS.error; + dot = fg(indicatorColor) + bold + "●" + reset; + const errs = this.console.filter((m) => m.level === "error").length; + label = errs > 1 ? `error · ${errs} issues` : "error"; + } else if (state === "timeout") { + indicatorColor = COLORS.error; + dot = fg(indicatorColor) + bold + "⏱" + reset; + label = `timed out (${this.cellTimeoutMs}ms)`; + } else { + indicatorColor = COLORS.dimText; + dot = fg(indicatorColor) + "○" + reset; + label = "idle"; + } + + const labelStyled = fg(indicatorColor) + label + reset; + const runBtn = ` ${fg(COLORS.fg)}[ Run ^S ]${reset}`; + const rightInfo = `${dot} ${labelStyled} ${runBtn}`; + const rightLen = visibleLength(rightInfo); + this.grid.writeStyled(0, this.cols - rightLen - 1, rightInfo, headerStyle); + + // Divider row + const divStyle = fg(COLORS.border); + this.grid.fillRect(1, 0, 2, this.cols, "─", divStyle); + } + + drawEditor() { + const box = this.editorBox(); + const {row: cRow} = this.buffer.posToRowCol(this.buffer.cursor); + const sel = this.buffer.selection(); + let highlightState = {inBlockComment: false}; + for (let line = 0; line < this.scrollY; line++) { + highlightState = nextHighlightState(this.buffer.lineText(line), highlightState); + } + + for (let i = 0; i < box.height; i++) { + const screenRow = box.top + i; + const line = this.scrollY + i; + if (line >= this.buffer.lineCount) { + // Empty filler row, render a soft tilde gutter + this.grid.writeStyled(screenRow, 0, fg(COLORS.ln) + " ~ " + reset, ""); + continue; + } + this.editorRowMap.set(screenRow, {line, pos: this.buffer.lineStarts[line]}); + const isCursorLine = line === cRow; + const text = this.buffer.lineText(line); + + // Gutter: line number, right-aligned in (GUTTER-1) chars + 1 space padding. + const lnText = String(line + 1); + const lnPad = " ".repeat(Math.max(0, GUTTER - 1 - lnText.length)); + const lnStyle = fg(isCursorLine ? COLORS.lnActive : COLORS.ln); + const gutter = lnPad + lnStyle + lnText + reset + " "; + this.grid.writeStyled(screenRow, 0, gutter, ""); + + // Source line content (with horizontal scroll). + const styled = highlightLine(text, highlightState); + writeStyledClipped(this.grid, screenRow, GUTTER - this.scrollX, styled, GUTTER, box.right); + highlightState = nextHighlightState(text, highlightState); + + // Cursor-line subtle background overlay — appended to each cell's + // style so the bg wins even when the highlighted token's style + // contains an inline `reset`. + if (isCursorLine) { + const lnBg = bg(COLORS.cursorLineBg); + for (let c = GUTTER; c < box.right; c++) { + const cell = this.grid.cells[screenRow * this.cols + c]; + if (cell) cell.style = cell.style + lnBg; + } + } + + // Selection overlay — paint inverse over selected cells. + if (sel) { + const lineStart = this.buffer.lineStarts[line]; + const lineEnd = this.buffer.lineRange(line).end; + const selFrom = Math.max(sel.from, lineStart); + const selTo = Math.min(sel.to, lineEnd); + if (selFrom < selTo) { + const a = selFrom - lineStart - this.scrollX; + const b = selTo - lineStart - this.scrollX; + const x0 = GUTTER + Math.max(0, a); + const x1 = GUTTER + Math.max(0, b); + if (x1 > x0) { + const overlayStyle = bg(COLORS.selBg) + fg(255); + // Keep the existing characters, just override style. + const cols = Math.min(box.right, x1) - x0; + for (let c = 0; c < cols; c++) { + const cell = this.grid.cells[screenRow * this.cols + x0 + c]; + if (cell) cell.style = overlayStyle; + } + } + } + } + } + } + + drawScrollbar() { + const col = this.scrollbarCol(); + const box = this.editorBox(); + const state = this.scrollbarState(box); + if (col < 0 || !state) return; + const trackStyle = fg(COLORS.border); + const thumbStyle = fg(this.scrollbarDragging ? COLORS.hot : COLORS.dimText); + for (let row = state.trackTop; row < state.trackBottom; row++) { + const inThumb = row >= state.thumbTop && row < state.thumbBottom; + this.grid.setCell(row, col, inThumb ? "█" : "│", inThumb ? thumbStyle : trackStyle); + } + } + + drawFooter() { + const divStyle = fg(COLORS.border); + this.grid.fillRect(this.rows - FOOTER_ROWS, 0, this.rows - FOOTER_ROWS + 1, this.cols, "─", divStyle); + const status = this.buildStatusLine(); + const statusStyle = bg(234) + fg(COLORS.status); + this.grid.fillRect(this.rows - 1, 0, this.rows, this.cols, " ", statusStyle); + this.grid.writeStyled(this.rows - 1, 0, statusStyle + status + reset, statusStyle); + } + + buildStatusLine() { + const {row, col} = this.buffer.posToRowCol(this.buffer.cursor); + const pos = ` Ln ${row + 1}, Col ${col + 1} `; + const left = bold + pos + reset; + + // Hints, in priority order (most useful first). Drop tail items until + // the whole line fits the terminal width. + const unread = this.unseenErrorCount(); + const journalLabel = unread > 0 ? `Console (${unread})` : `Console`; + const allHints: [string, string][] = [ + [`^S`, `Run`], + [`^E`, `Examples`], + [`^L`, journalLabel], + [`^N`, `New`], + [`^O`, `Open`], + [`^W`, `Save`], + [`^T`, `Rename`], + [`^X`, `Stop`], + [`^R`, `Reset`], + [`^K`, `Help`], + [`^Q`, `Quit`], + ]; + const renderHints = (items: [string, string][]) => + items + .map(([k, l]) => { + const labelColor = l.startsWith("Console") && unread > 0 ? fg(COLORS.error) : ""; + return `${fg(COLORS.hot)}${k}${reset} ${labelColor}${l}${reset}`; + }) + .join(" · "); + + let right; + if (this.message) { + right = " " + fg(COLORS.marker) + this.message + reset + " "; + } else { + const items = allHints.slice(); + let candidate = " " + renderHints(items) + " "; + const leftLen = visibleLength(left); + while (items.length > 2 && leftLen + visibleLength(candidate) > this.cols) { + items.pop(); + candidate = " " + renderHints(items) + " "; + } + right = candidate; + } + const leftLen = visibleLength(left); + const rightLen = visibleLength(right); + const filler = Math.max(1, this.cols - leftLen - rightLen); + return left + " ".repeat(filler) + right; + } + + // ------------------------------------------------------------------------- + // Modals + drawModal() { + if (this.activeModal.type === "examples") return this.drawExamplesModal(); + if (this.activeModal.type === "input") return this.drawInputModal(); + if (this.activeModal.type === "help") return this.drawHelpModal(); + if (this.activeModal.type === "console") return this.drawConsoleModal(); + if (this.activeModal.type === "confirm") return this.drawConfirmModal(); + } + + onModalInput(chunk: string) { + const events = scr.parseInput(chunk); + for (const ev of events) { + if (this.activeModal.type === "examples") this.handleExamplesKey(ev); + else if (this.activeModal.type === "input") this.handleInputKey(ev); + else if (this.activeModal.type === "help") this.handleHelpKey(ev); + else if (this.activeModal.type === "console") this.handleConsoleKey(ev); + else if (this.activeModal.type === "confirm") this.handleConfirmKey(ev); + } + } + + // ---- examples picker + openExamples() { + if (!this.examplesDir) { + this.flash("No examples directory available", 2000); + return; + } + let entries: string[] = []; + try { + entries = fs + .readdirSync(this.examplesDir) + .filter((f) => f.endsWith(".recho.js")) + .sort(); + } catch (e) { + this.flash("Cannot read examples: " + errorMessage(e), 3000); + return; + } + this.modal = {type: "examples", entries, index: 0, query: "", scroll: 0}; + this.dirty = true; + } + + filteredExamples(): string[] { + const {entries, query} = this.activeModal; + if (!query) return entries; + const q = query.toLowerCase(); + return entries.filter((e: string) => e.toLowerCase().includes(q)); + } + + handleExamplesKey(ev: scr.InputEvent) { + if (ev.type === "key") { + const {name, ctrl} = ev; + if (name === "escape" || (ctrl && name === "g")) { + this.modal = null; + this.dirty = true; + return; + } + if (name === "enter") return this.confirmExample(); + if (name === "down") { + this.activeModal.index = Math.min(this.filteredExamples().length - 1, this.activeModal.index + 1); + this.dirty = true; + return; + } + if (name === "up") { + this.activeModal.index = Math.max(0, this.activeModal.index - 1); + this.dirty = true; + return; + } + if (name === "backspace") { + this.activeModal.query = this.activeModal.query.slice(0, -1); + this.activeModal.index = 0; + this.dirty = true; + return; + } + if (name === "pagedown") { + this.activeModal.index = Math.min(this.filteredExamples().length - 1, this.activeModal.index + 10); + this.dirty = true; + return; + } + if (name === "pageup") { + this.activeModal.index = Math.max(0, this.activeModal.index - 10); + this.dirty = true; + return; + } + } else if (ev.type === "text") { + this.activeModal.query += ev.text; + this.activeModal.index = 0; + this.dirty = true; + } else if (ev.type === "mouse") { + if (ev.kind === "wheel-up") { + this.activeModal.index = Math.max(0, this.activeModal.index - 3); + this.dirty = true; + } else if (ev.kind === "wheel-down") { + this.activeModal.index = Math.min(this.filteredExamples().length - 1, this.activeModal.index + 3); + this.dirty = true; + } else if (ev.kind === "press" && ev.button === 0) { + // Click in list to select; click outside to dismiss. + const box = this.modalBox(); + const listTop = box.top + 4; + const listLeft = box.left + 1; + const listRight = box.left + box.width - 1; + const i = ev.row - listTop + (this.activeModal.scroll || 0); + if ( + ev.row >= listTop && + ev.row < box.top + box.height - 1 && + ev.col >= listLeft && + ev.col < listRight && + i >= 0 && + i < this.filteredExamples().length + ) { + this.activeModal.index = i; + this.confirmExample(); + } + } + } + } + + confirmExample() { + if (!this.examplesDir) return; + const list = this.filteredExamples(); + const name = list[this.activeModal.index]; + if (!name) return; + const file = path.join(this.examplesDir, name); + try { + const code = fs.readFileSync(file, "utf8"); + this.path = file; + this.buffer = new DocumentBuffer(code); + this.scrollY = 0; + this.scrollX = 0; + this.modal = null; + this.initRuntime(); + this.flash("Loaded " + name, 2000); + } catch (e) { + this.flash("Failed to load: " + errorMessage(e), 3000); + } + this.dirty = true; + } + + modalBox(): Box { + const w = Math.min(80, this.cols - 8); + const h = Math.min(this.rows - 6, 22); + const left = Math.max(2, Math.floor((this.cols - w) / 2)); + const top = Math.max(2, Math.floor((this.rows - h) / 2)); + return {top, left, width: w, height: h}; + } + + drawExamplesModal() { + const box = this.modalBox(); + drawBox(this.grid, box, " Examples · " + this.filteredExamples().length + " ", COLORS); + const queryLine = box.top + 2; + this.grid.writeStyled( + queryLine, + box.left + 2, + fg(COLORS.dimText) + "filter: " + reset + this.activeModal.query + fg(COLORS.dimText) + "▏" + reset, + "", + ); + const items = this.filteredExamples(); + const listTop = box.top + 4; + const listH = box.height - 5; + // Keep selected in view. + let scroll = this.activeModal.scroll || 0; + if (this.activeModal.index < scroll) scroll = this.activeModal.index; + if (this.activeModal.index >= scroll + listH) scroll = this.activeModal.index - listH + 1; + this.activeModal.scroll = scroll; + for (let i = 0; i < listH; i++) { + const idx = scroll + i; + if (idx >= items.length) break; + const name = items[idx]; + const display = formatExampleName(name); + const selected = idx === this.activeModal.index; + const style = selected ? bg(COLORS.selBg) + fg(255) + bold : fg(COLORS.fg); + const arrow = selected ? fg(COLORS.hot) + "▸ " + reset : " "; + const line = arrow + style + " " + padToWidth(display, box.width - 6) + " " + reset; + // Fill row bg first + if (selected) + this.grid.fillRect(listTop + i, box.left + 1, listTop + i + 1, box.left + box.width - 1, " ", bg(COLORS.selBg)); + this.grid.writeStyled(listTop + i, box.left + 2, line, ""); + } + const help = fg(COLORS.dimText) + "↑/↓ select · type to filter · Enter to load · Esc to cancel" + reset; + this.grid.writeStyled(box.top + box.height - 1, box.left + 2, help, ""); + } + + // ---- input prompt + inputPrompt(title: string, initial: string, onSubmit: (value: string) => void) { + this.modal = {type: "input", title, value: initial, onSubmit}; + this.dirty = true; + } + + handleInputKey(ev: scr.InputEvent) { + if (ev.type === "key") { + if (ev.name === "escape") { + this.modal = null; + this.dirty = true; + return; + } + if (ev.name === "enter") { + const v = this.activeModal.value; + const cb = this.activeModal.onSubmit; + this.modal = null; + this.dirty = true; + cb(v); + return; + } + if (ev.name === "backspace") { + this.activeModal.value = this.activeModal.value.slice(0, -1); + this.dirty = true; + return; + } + } else if (ev.type === "text") { + this.activeModal.value += ev.text; + this.dirty = true; + } + } + + drawInputModal() { + const w = Math.min(70, this.cols - 8); + const h = 7; + const left = Math.max(2, Math.floor((this.cols - w) / 2)); + const top = Math.max(2, Math.floor((this.rows - h) / 2)); + const box = {top, left, width: w, height: h}; + drawBox(this.grid, box, " " + this.activeModal.title + " ", COLORS); + this.grid.writeStyled(top + 2, left + 2, fg(COLORS.dimText) + "value: " + reset, ""); + this.grid.writeStyled(top + 3, left + 2, this.activeModal.value + fg(COLORS.hot) + "▏" + reset, ""); + this.grid.writeStyled(top + h - 1, left + 2, fg(COLORS.dimText) + "Enter to confirm · Esc to cancel" + reset, ""); + } + + newFile() { + this.runtime?.setIsRunning(false); + this.path = null; + this.buffer = new DocumentBuffer(""); + this.scrollY = 0; + this.scrollX = 0; + this.runState = "idle"; + this.initRuntime(); + this.flash("New empty file", 1800); + this.dirty = true; + } + + openFilePrompt() { + this.inputPrompt("Open file", this.path || "", (p) => { + if (!p) return; + try { + const code = fs.readFileSync(p, "utf8"); + this.path = path.resolve(p); + this.buffer = new DocumentBuffer(code); + this.scrollY = 0; + this.scrollX = 0; + this.initRuntime(); + this.flash("Opened " + p, 2000); + } catch (e) { + this.flash("Open failed: " + errorMessage(e), 3000); + } + this.dirty = true; + }); + } + + saveToPath(filePath: string) { + fs.writeFileSync(filePath, this.buffer.text, "utf8"); + this.path = path.resolve(filePath); + } + + savePrompt() { + this.inputPrompt("Save to", this.path || "untitled.recho.js", (p) => { + if (!p) return; + try { + this.saveToPath(p); + this.flash("Saved " + p, 2000); + } catch (e) { + this.flash("Save failed: " + errorMessage(e), 3000); + } + this.dirty = true; + }); + } + + renamePrompt() { + this.inputPrompt("Rename file", this.path || "untitled.recho.js", (p) => { + if (!p) return; + const target = path.resolve(p); + const current = this.path ? path.resolve(this.path) : null; + try { + if (current === target) { + this.flash("Already named " + path.basename(target), 1600); + return; + } + if (fs.existsSync(target)) { + this.flash("Rename failed: target already exists", 3000); + return; + } + if (current && fs.existsSync(current)) fs.renameSync(current, target); + this.saveToPath(target); + this.flash("Renamed to " + path.basename(target), 2200); + } catch (e) { + this.flash("Rename failed: " + errorMessage(e), 3000); + } + this.dirty = true; + }); + } + + // ---- help + ensureHelpDocs(): HelpDocs | null { + if (this.helpDocs) return this.helpDocs; + if (!this.docsDir) return null; + try { + this.helpDocs = loadHelpDocs(this.docsDir); + return this.helpDocs; + } catch (e) { + this.flash("Cannot read tutorials: " + errorMessage(e), 3000); + return null; + } + } + + openHelp() { + const help = this.ensureHelpDocs(); + if (!help || help.entries.length === 0) { + this.flash("No tutorials available", 2000); + return; + } + const helpIndex = this.firstSelectableHelpIndex(help); + const entry = help.entries[helpIndex]; + if (!entry?.slug) { + this.flash("No tutorials available", 2000); + return; + } + this.modal = { + type: "help", + help, + helpIndex, + helpSlug: entry.slug, + helpScroll: 0, + helpOutlineScroll: 0, + }; + this.dirty = true; + } + + firstSelectableHelpIndex(help: HelpDocs): number { + return Math.max( + 0, + help.entries.findIndex((entry) => entry.selectable && entry.slug), + ); + } + + moveHelpSelection(delta: number) { + const help = this.activeModal.help; + let next = this.activeModal.helpIndex; + while (next + delta >= 0 && next + delta < help.entries.length) { + next += delta; + const entry = help.entries[next]; + if (entry.selectable && entry.slug) { + this.setHelpSelection(next); + return; + } + } + } + + setHelpSelection(index: number) { + const entry = this.activeModal.help.entries[index]; + if (!entry?.selectable || !entry.slug) return; + this.activeModal.helpIndex = index; + this.activeModal.helpSlug = entry.slug; + this.activeModal.helpScroll = 0; + this.dirty = true; + } + + scrollHelpContent(delta: number) { + this.activeModal.helpScroll = Math.max(0, Math.min(this.maxHelpScroll(), this.activeModal.helpScroll + delta)); + this.dirty = true; + } + + maxHelpScroll(): number { + const doc = this.activeModal.help.docsBySlug.get(this.activeModal.helpSlug); + if (!doc) return 0; + const layout = this.helpLayout(); + return Math.max(0, doc.lines.length - layout.contentHeight); + } + + closeHelp() { + this.modal = null; + this.dirty = true; + } + + handleHelpKey(ev: scr.InputEvent) { + if (ev.type === "key") { + const {name, ctrl} = ev; + if (name === "escape" || (ctrl && name === "k") || (ctrl && name === "g")) return this.closeHelp(); + if (name === "down") return this.moveHelpSelection(1); + if (name === "up") return this.moveHelpSelection(-1); + if (name === "pagedown") return this.scrollHelpContent(this.helpLayout().contentHeight); + if (name === "pageup") return this.scrollHelpContent(-this.helpLayout().contentHeight); + if (name === "home") { + this.setHelpSelection(this.firstSelectableHelpIndex(this.activeModal.help)); + return; + } + if (name === "end") { + for (let i = this.activeModal.help.entries.length - 1; i >= 0; i--) { + const entry = this.activeModal.help.entries[i]; + if (entry.selectable && entry.slug) { + this.setHelpSelection(i); + return; + } + } + } + return; + } + + if (ev.type !== "mouse") return; + const layout = this.helpLayout(); + const back = this.helpBackButtonBounds(); + if (ev.kind === "press" && ev.button === 0 && ev.row === back.row && ev.col >= back.left && ev.col < back.right) { + return this.closeHelp(); + } + if (ev.kind === "wheel-up") { + if (ev.col <= layout.outlineRight) this.moveHelpSelection(-3); + else this.scrollHelpContent(-3); + return; + } + if (ev.kind === "wheel-down") { + if (ev.col <= layout.outlineRight) this.moveHelpSelection(3); + else this.scrollHelpContent(3); + return; + } + if ( + ev.kind === "press" && + ev.button === 0 && + ev.row >= layout.bodyTop && + ev.row < layout.bodyBottom && + ev.col >= 0 && + ev.col < layout.outlineRight + ) { + const index = this.activeModal.helpOutlineScroll + (ev.row - layout.bodyTop); + this.setHelpSelection(index); + } + } + + helpLayout() { + const bodyTop = 2; + const footer = Math.max(bodyTop, this.rows - 1); + const bodyBottom = footer; + const bodyHeight = Math.max(0, bodyBottom - bodyTop); + const outlineWidth = Math.min(Math.max(18, Math.floor(this.cols * 0.32)), 36, Math.max(18, this.cols - 24)); + const outlineRight = Math.max(12, outlineWidth); + const contentLeft = Math.min(this.cols - 1, outlineRight + 2); + const contentWidth = Math.max(0, this.cols - contentLeft - 1); + const contentTop = bodyTop + 2; + const contentHeight = Math.max(0, bodyBottom - contentTop); + return {bodyTop, bodyBottom, bodyHeight, outlineRight, contentLeft, contentWidth, contentTop, contentHeight}; + } + + helpBackButtonBounds() { + const text = "[ Editor Esc ]"; + const width = text.length + 2; + const left = Math.max(0, this.cols - width - 1); + return {row: 0, left, right: Math.min(this.cols, left + width), text}; + } + + drawHelpModal() { + const layout = this.helpLayout(); + const frameStyle = bg(233) + fg(COLORS.fg); + this.grid.fillRect(0, 0, this.rows, this.cols, " ", frameStyle); + + const headerStyle = bg(234) + fg(COLORS.title); + this.grid.fillRect(0, 0, 1, this.cols, " ", headerStyle); + this.grid.writeStyled(0, 0, headerStyle + bold + " Recho · Tutorials " + reset, headerStyle); + const back = this.helpBackButtonBounds(); + this.grid.writeStyled(0, back.left, headerStyle + fg(COLORS.hot) + " " + back.text + " " + reset, headerStyle); + + const divStyle = fg(COLORS.border); + this.grid.fillRect(1, 0, 2, this.cols, "─", divStyle); + for (let row = layout.bodyTop; row < layout.bodyBottom; row++) { + this.grid.setCell(row, layout.outlineRight, "│", divStyle + frameStyle); + } + + this.drawHelpOutline(layout); + this.drawHelpContent(layout); + + const footerStyle = bg(234) + fg(COLORS.status); + this.grid.fillRect(this.rows - 1, 0, this.rows, this.cols, " ", footerStyle); + const helpLine = + ` ${fg(COLORS.hot)}↑/↓${reset} outline · ${fg(COLORS.hot)}PgUp/PgDn${reset} scroll · ` + + `${fg(COLORS.hot)}click${reset} open · ${fg(COLORS.hot)}Esc/^K${reset} editor `; + this.grid.writeStyled(this.rows - 1, 0, footerStyle + helpLine + reset, footerStyle); + } + + drawHelpOutline(layout: ReturnType) { + const help = this.activeModal.help; + const listH = layout.bodyHeight; + let scroll = this.activeModal.helpOutlineScroll || 0; + if (this.activeModal.helpIndex < scroll) scroll = this.activeModal.helpIndex; + if (this.activeModal.helpIndex >= scroll + listH) scroll = this.activeModal.helpIndex - listH + 1; + this.activeModal.helpOutlineScroll = Math.max(0, scroll); + + for (let i = 0; i < listH; i++) { + const index = this.activeModal.helpOutlineScroll + i; + const entry = help.entries[index]; + if (!entry) break; + const row = layout.bodyTop + i; + const selected = index === this.activeModal.helpIndex; + if (selected) this.grid.fillRect(row, 0, row + 1, layout.outlineRight, " ", bg(COLORS.selBg)); + const baseStyle = + entry.kind === "group" + ? fg(entry.selectable ? COLORS.title : COLORS.dimText) + bold + : fg(entry.selectable ? COLORS.fg : COLORS.dimText); + const selectedStyle = selected ? bg(COLORS.selBg) + fg(255) + bold : baseStyle; + const prefix = selected ? fg(COLORS.hot) + "▸ " + reset : " "; + const indent = " ".repeat(entry.depth); + const width = Math.max(1, layout.outlineRight - 4); + const label = padToWidth(indent + entry.title, width).slice(0, width); + this.grid.writeStyled(row, 1, prefix + selectedStyle + label + reset, ""); + } + } + + drawHelpContent(layout: ReturnType) { + const doc = this.activeModal.help.docsBySlug.get(this.activeModal.helpSlug); + if (!doc) return; + this.activeModal.helpScroll = Math.max(0, Math.min(this.maxHelpScroll(), this.activeModal.helpScroll)); + + this.grid.writeStyled(layout.bodyTop, layout.contentLeft, fg(COLORS.title) + bold + doc.title + reset, ""); + this.grid.writeStyled( + layout.bodyTop + 1, + layout.contentLeft, + fg(COLORS.dimText) + doc.slug + ".recho.js" + reset, + "", + ); + + let state = {inBlockComment: false}; + for (let i = 0; i < this.activeModal.helpScroll; i++) state = nextHighlightState(doc.lines[i] ?? "", state); + + for (let i = 0; i < layout.contentHeight; i++) { + const index = this.activeModal.helpScroll + i; + const line = doc.lines[index]; + if (line == null) break; + this.grid.writeStyled(layout.contentTop + i, layout.contentLeft, highlightLine(line, state), ""); + state = nextHighlightState(line, state); + } + } + + // ---- console / messages log + openConsole() { + this.consoleSeen = this.console.length; + this.modal = {type: "console", scroll: Math.max(0, this.console.length - 1)}; + this.dirty = true; + } + + handleConsoleKey(ev: scr.InputEvent) { + if (ev.type === "key") { + const {name, ctrl} = ev; + if (name === "escape" || (ctrl && name === "l") || (ctrl && name === "g")) { + this.modal = null; + this.dirty = true; + return; + } + if (name === "up") { + this.activeModal.scroll = Math.max(0, this.activeModal.scroll - 1); + this.dirty = true; + return; + } + if (name === "down") { + this.activeModal.scroll = Math.min(this.console.length - 1, this.activeModal.scroll + 1); + this.dirty = true; + return; + } + if (name === "pageup") { + this.activeModal.scroll = Math.max(0, this.activeModal.scroll - 10); + this.dirty = true; + return; + } + if (name === "pagedown") { + this.activeModal.scroll = Math.min(this.console.length - 1, this.activeModal.scroll + 10); + this.dirty = true; + return; + } + if (name === "home") { + this.activeModal.scroll = 0; + this.dirty = true; + return; + } + if (name === "end") { + this.activeModal.scroll = Math.max(0, this.console.length - 1); + this.dirty = true; + return; + } + if (name === "delete" || name === "backspace") { + this.console = []; + this.consoleSeen = 0; + this.activeModal.scroll = 0; + this.flash("Console cleared", 1200); + this.dirty = true; + return; + } + } else if (ev.type === "mouse") { + if (ev.kind === "wheel-up") { + this.activeModal.scroll = Math.max(0, this.activeModal.scroll - 3); + this.dirty = true; + } else if (ev.kind === "wheel-down") { + this.activeModal.scroll = Math.min(this.console.length - 1, this.activeModal.scroll + 3); + this.dirty = true; + } + } + } + + drawConsoleModal() { + const w = Math.min(96, this.cols - 6); + const h = Math.min(this.rows - 4, 24); + const left = Math.max(2, Math.floor((this.cols - w) / 2)); + const top = Math.max(2, Math.floor((this.rows - h) / 2)); + const box = {top, left, width: w, height: h}; + const errors = this.console.filter((m) => m.level === "error").length; + const warns = this.console.filter((m) => m.level === "warn").length; + const title = ` Console · ${this.console.length} entries · ${errors} errors · ${warns} warnings `; + drawBox(this.grid, box, title, COLORS); + if (this.console.length === 0) { + this.grid.writeStyled( + top + Math.floor(h / 2), + left + 2, + fg(COLORS.dimText) + "(empty — no notebook errors yet)" + reset, + "", + ); + this.grid.writeStyled(top + h - 1, left + 2, fg(COLORS.dimText) + "Esc / ^J to close" + reset, ""); + return; + } + // Render messages as wrapped blocks: each entry's first line on its + // anchor row, continuation lines indented two columns. + const innerW = w - 4; + const lines: {entryIndex: number; level: string; text: string}[] = []; + for (let i = 0; i < this.console.length; i++) { + const m = this.console[i]; + const tag = + m.level === "error" + ? fg(COLORS.error) + bold + "✗" + reset + : m.level === "warn" + ? fg(COLORS.hot) + "!" + reset + : fg(COLORS.marker) + "•" + reset; + const ts = new Date(m.ts).toTimeString().slice(0, 8); + const counter = m.count > 1 ? ` ${fg(COLORS.hot)}×${m.count}${reset}` : ""; + const head = `${tag} ${fg(COLORS.dimText)}${ts}${reset}${counter} `; + const headLen = visibleLength(head); + // Plain-text wrap (don't try to keep ANSI inside the message body). + const body = m.text; + const bodyLines = body.split("\n"); + let first = true; + for (const bl of bodyLines) { + // word-wrap each source line + let buf = bl; + while (buf.length > 0) { + const slice = buf.slice(0, innerW - (first ? headLen : 2)); + buf = buf.slice(slice.length); + const prefix = first ? head : " "; + const styled = first ? slice : fg(COLORS.dimText) + slice + reset; + lines.push({entryIndex: i, level: m.level, text: prefix + styled}); + first = false; + } + if (bodyLines.length > 1 && bl !== bodyLines[bodyLines.length - 1]) { + // also indented continuation + } + } + } + // Ensure scroll keeps the requested entry visible at the bottom. + const listH = h - 3; + let firstLine = 0; + // Find the line index for `this.activeModal.scroll` entry. + for (let i = 0; i < lines.length; i++) { + if (lines[i].entryIndex >= this.activeModal.scroll) { + firstLine = Math.max(0, i - listH + 1); + break; + } + } + if (firstLine + listH > lines.length) firstLine = Math.max(0, lines.length - listH); + for (let row = 0; row < listH; row++) { + const li = firstLine + row; + if (li >= lines.length) break; + this.grid.writeStyled(top + 1 + row, left + 2, lines[li].text, ""); + } + const help = fg(COLORS.dimText) + "↑/↓ scroll · Home/End jump · ⌫ clear · Esc / ^L close" + reset; + this.grid.writeStyled(top + h - 1, left + 2, help, ""); + } + + // ---- confirm + drawConfirmModal() {} + handleConfirmKey(ev?: scr.InputEvent) { + void ev; + } +} + +function cloneGrid(g: scr.Grid): scr.Grid { + const c = new scr.Grid(g.rows, g.cols); + for (let i = 0; i < g.cells.length; i++) { + c.cells[i].ch = g.cells[i].ch; + c.cells[i].style = g.cells[i].style; + } + return c; +} + +function writeStyledClipped( + grid: scr.Grid, + row: number, + col: number, + s: string, + clipLeft: number, + clipRight: number, + baseStyle = "", +) { + if (row < 0 || row >= grid.rows) return; + let style = baseStyle; + let i = 0; + let c = col; + while (i < s.length && c < clipRight) { + if (s[i] === "\x1b" && s[i + 1] === "[") { + const end = s.indexOf("m", i); + if (end === -1) break; + style += s.slice(i, end + 1); + i = end + 1; + continue; + } + const ch = s[i]; + if (ch !== "\n" && ch !== "\r" && c >= clipLeft) grid.setCell(row, c, ch, style); + c++; + i++; + } +} + +function drawBox(grid: scr.Grid, box: Box, title: string, palette: typeof COLORS) { + const {top, left, width, height} = box; + const right = left + width - 1; + const bottom = top + height - 1; + const border = fg(palette.borderHi); + const inside = bg(233); + // Fill background + grid.fillRect(top, left, top + height, left + width, " ", inside); + // Borders + for (let c = left + 1; c < right; c++) { + grid.setCell(top, c, "─", border + inside); + grid.setCell(bottom, c, "─", border + inside); + } + for (let r = top + 1; r < bottom; r++) { + grid.setCell(r, left, "│", border + inside); + grid.setCell(r, right, "│", border + inside); + } + grid.setCell(top, left, "╭", border + inside); + grid.setCell(top, right, "╮", border + inside); + grid.setCell(bottom, left, "╰", border + inside); + grid.setCell(bottom, right, "╯", border + inside); + // Title + if (title) { + const t = " " + title + " "; + const tcol = left + 2; + grid.writeStyled(top, tcol, bg(233) + fg(palette.title) + bold + t + reset, ""); + } +} + +function formatExampleName(name: string): string { + return name.replace(/\.recho\.js$/, "").replace(/-/g, " "); +} diff --git a/terminal/buffer.ts b/terminal/buffer.ts new file mode 100644 index 0000000..ea97026 --- /dev/null +++ b/terminal/buffer.ts @@ -0,0 +1,234 @@ +// Document buffer with character-offset edits, line index, and cursor +// helpers. Positions are byte offsets in the internal string but since we +// only use ASCII + simple Unicode, that maps 1:1 to characters here. +// +// A `change` is `{from: number, to?: number, insert: string}` matching the +// CodeMirror change-spec used by the runtime. + +export type ChangeSpec = {from: number; to?: number; insert?: string}; +export type LineRange = {start: number; end: number}; +export type RowCol = {row: number; col: number}; +export type Selection = {from: number; to: number}; + +export class Buffer { + text: string; + lineStarts: number[]; + cursor: number; + anchor: number | null; + preferredCol: number; + + constructor(initial = "") { + this.text = initial; + this.lineStarts = computeLineStarts(this.text); + // Cursor as character offset. + this.cursor = 0; + // Selection anchor (null if no selection). + this.anchor = null; + // Preferred column for vertical movement. + this.preferredCol = 0; + } + + get length() { + return this.text.length; + } + + get lineCount() { + return this.lineStarts.length; + } + + lineRange(line: number): LineRange { + const start = this.lineStarts[line]; + const end = line + 1 < this.lineStarts.length ? this.lineStarts[line + 1] - 1 : this.text.length; + return {start, end}; + } + + lineText(line: number): string { + const {start, end} = this.lineRange(line); + return this.text.slice(start, end); + } + + posToRowCol(pos: number): RowCol { + pos = Math.max(0, Math.min(pos, this.text.length)); + // Binary search in lineStarts. + let lo = 0; + let hi = this.lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (this.lineStarts[mid] <= pos) lo = mid; + else hi = mid - 1; + } + return {row: lo, col: pos - this.lineStarts[lo]}; + } + + rowColToPos(row: number, col: number): number { + row = Math.max(0, Math.min(row, this.lineStarts.length - 1)); + const start = this.lineStarts[row]; + const end = row + 1 < this.lineStarts.length ? this.lineStarts[row + 1] - 1 : this.text.length; + return Math.max(start, Math.min(start + col, end)); + } + + // Apply an array of CodeMirror-style change specs in document order. The + // cursor is mapped through the changes (associativity = right). + applyChanges(changes: ChangeSpec[]) { + // Sort by `from` ascending, with delete-only first to keep ordering + // deterministic. Runtime emits non-overlapping changes. + const sorted = changes + .map((c) => ({from: c.from, to: c.to ?? c.from, insert: c.insert ?? ""})) + .sort((a, b) => a.from - b.from); + let offset = 0; + let text = this.text; + let cursor = this.cursor; + let anchor = this.anchor; + for (const c of sorted) { + const from = c.from + offset; + const to = c.to + offset; + text = text.slice(0, from) + c.insert + text.slice(to); + const delta = c.insert.length - (to - from); + cursor = mapPos(cursor, from, to, c.insert.length, delta); + if (anchor !== null) anchor = mapPos(anchor, from, to, c.insert.length, delta); + offset += delta; + } + this.text = text; + this.cursor = cursor; + this.anchor = anchor; + this.lineStarts = computeLineStarts(this.text); + this.preferredCol = this.posToRowCol(this.cursor).col; + } + + insertAtCursor(s: string) { + if (this.anchor !== null) this.deleteSelection(); + const at = this.cursor; + this.text = this.text.slice(0, at) + s + this.text.slice(at); + this.cursor = at + s.length; + this.lineStarts = computeLineStarts(this.text); + this.preferredCol = this.posToRowCol(this.cursor).col; + } + + deleteSelection(): boolean { + if (this.anchor === null) return false; + const from = Math.min(this.anchor, this.cursor); + const to = Math.max(this.anchor, this.cursor); + if (from === to) { + this.anchor = null; + return false; + } + this.text = this.text.slice(0, from) + this.text.slice(to); + this.cursor = from; + this.anchor = null; + this.lineStarts = computeLineStarts(this.text); + this.preferredCol = this.posToRowCol(this.cursor).col; + return true; + } + + backspace() { + if (this.deleteSelection()) return; + if (this.cursor === 0) return; + this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); + this.cursor -= 1; + this.lineStarts = computeLineStarts(this.text); + this.preferredCol = this.posToRowCol(this.cursor).col; + } + + del() { + if (this.deleteSelection()) return; + if (this.cursor === this.text.length) return; + this.text = this.text.slice(0, this.cursor) + this.text.slice(this.cursor + 1); + this.lineStarts = computeLineStarts(this.text); + this.preferredCol = this.posToRowCol(this.cursor).col; + } + + // Cursor movement. `extend` keeps the selection anchor (creates one if none). + moveTo(pos: number, extend = false) { + pos = Math.max(0, Math.min(pos, this.text.length)); + if (extend) { + if (this.anchor === null) this.anchor = this.cursor; + } else { + this.anchor = null; + } + this.cursor = pos; + this.preferredCol = this.posToRowCol(this.cursor).col; + } + + moveLeft(extend = false) { + this.moveTo(Math.max(0, this.cursor - 1), extend); + } + moveRight(extend = false) { + this.moveTo(Math.min(this.text.length, this.cursor + 1), extend); + } + moveUp(extend = false) { + const {row} = this.posToRowCol(this.cursor); + if (row === 0) return this.moveTo(0, extend); + const {start, end} = this.lineRange(row - 1); + const col = Math.min(this.preferredCol, end - start); + if (extend) { + if (this.anchor === null) this.anchor = this.cursor; + } else { + this.anchor = null; + } + this.cursor = start + col; + } + moveDown(extend = false) { + const {row} = this.posToRowCol(this.cursor); + if (row >= this.lineStarts.length - 1) return this.moveTo(this.text.length, extend); + const {start, end} = this.lineRange(row + 1); + const col = Math.min(this.preferredCol, end - start); + if (extend) { + if (this.anchor === null) this.anchor = this.cursor; + } else { + this.anchor = null; + } + this.cursor = start + col; + } + moveHome(extend = false) { + const {row} = this.posToRowCol(this.cursor); + const {start, end} = this.lineRange(row); + // Smart-home: jump to first non-whitespace char first. + const line = this.text.slice(start, end); + const indent = line.match(/^\s*/)?.[0].length ?? 0; + const target = this.cursor === start + indent ? start : start + indent; + this.moveTo(target, extend); + } + moveEnd(extend = false) { + const {row} = this.posToRowCol(this.cursor); + const {end} = this.lineRange(row); + this.moveTo(end, extend); + } + + // Word-boundary helpers (for Alt+Left/Right). + wordLeft(extend = false) { + let p = this.cursor; + if (p === 0) return; + p--; + while (p > 0 && /\s/.test(this.text[p])) p--; + while (p > 0 && /\w/.test(this.text[p - 1])) p--; + this.moveTo(p, extend); + } + wordRight(extend = false) { + let p = this.cursor; + const n = this.text.length; + while (p < n && !/\w/.test(this.text[p])) p++; + while (p < n && /\w/.test(this.text[p])) p++; + this.moveTo(p, extend); + } + + selection(): Selection | null { + if (this.anchor === null) return null; + return {from: Math.min(this.anchor, this.cursor), to: Math.max(this.anchor, this.cursor)}; + } +} + +function computeLineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) starts.push(i + 1); + } + return starts; +} + +function mapPos(pos: number, from: number, to: number, insLen: number, delta: number): number { + // Right-associative mapping (matches CodeMirror default for forward map). + if (pos <= from) return pos; + if (pos >= to) return pos + delta; + // Position landed inside the replaced range — clamp to end of insertion. + return from + insLen; +} diff --git a/terminal/cli.ts b/terminal/cli.ts new file mode 100755 index 0000000..43e0fde --- /dev/null +++ b/terminal/cli.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Terminal Recho — entry point. +// +// Usage: +// recho [file.recho.js] +// +// The runtime imports a couple of TypeScript modules. Node 24 supports +// transparent .ts loading for type-strip-friendly files; the project's +// .ts files have been adjusted to fit, so we don't need a flag. + +import fs from "node:fs"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {App} from "./app.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const examplesDir = path.join(repoRoot, "app", "examples"); +const docsDir = path.join(repoRoot, "app", "docs"); + +const DEFAULT_CODE = `// Welcome to Recho · the reactive notebook for your terminal. +// Edit code below and press ^S to run. Output appears as //➜ comments. + +const greet = (name) => echo(\`Hello, \${name}!\`); + +greet("world"); + +// A small loop — each \`echo\` lands inline above its expression. +for (let i = 1; i <= 5; i++) { + echo("*".repeat(i)); +} + +// Press ^E to browse examples (sorting, mazes, ASCII art, …) +// Press ^N for a new empty file, ^T to rename the current file. +`; + +function main() { + const args = process.argv.slice(2); + let initialPath = null; + let initialCode = DEFAULT_CODE; + + for (const a of args) { + if (a === "-h" || a === "--help") { + printHelp(); + process.exit(0); + } + if (!a.startsWith("-")) { + initialPath = path.resolve(a); + try { + initialCode = fs.readFileSync(initialPath, "utf8"); + } catch (e) { + console.error("Cannot read", initialPath + ":", e instanceof Error ? e.message : String(e)); + process.exit(1); + } + break; + } + } + + if (!process.stdout.isTTY) { + console.error("recho: stdout is not a TTY (need an interactive terminal)."); + process.exit(1); + } + + const app = new App({initialPath, initialCode, examplesDir, docsDir}); + app.start(); +} + +function printHelp() { + process.stdout.write( + [ + "Recho Notebook — terminal edition", + "", + "Usage:", + " recho open with the welcome buffer", + " recho path/to/file.js open an existing notebook source", + "", + "Inside the editor:", + " ^S run ^X stop ^R restart runtime", + " ^E examples ^N new file ^O open file", + " ^W save ^T rename file ^L console", + " ^K help ^Q quit", + "", + ].join("\n"), + ); +} + +main(); diff --git a/terminal/docs.ts b/terminal/docs.ts new file mode 100644 index 0000000..82b3c32 --- /dev/null +++ b/terminal/docs.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import {docsNavConfig} from "../app/docs/nav.config.js"; + +type DocsNavPage = {type: "page"; slug: string}; +type DocsNavGroup = {type: "group"; title: string; slug?: string; items: DocsNavPage[]}; +type DocsNavItem = DocsNavPage | DocsNavGroup; + +export type HelpDoc = { + slug: string; + title: string; + content: string; + lines: string[]; +}; + +export type HelpEntry = { + kind: "group" | "page"; + title: string; + slug: string | null; + depth: number; + selectable: boolean; +}; + +export type HelpDocs = { + entries: HelpEntry[]; + docsBySlug: Map; +}; + +type Meta = {title?: string}; + +function parseJSMeta(content: string): Meta { + const match = content.match(/^\/\*\*([\s\S]*?)\*\//); + if (!match) return {}; + const meta: Meta = {}; + for (const line of (match[1] ?? "").split("\n")) { + const m = line.match(/@(\w+)\s+(.*)/); + if (m?.[1] === "title") meta.title = (m[2] ?? "").trim(); + } + return meta; +} + +function removeJSMeta(content: string): string { + return content.replace(/^\/\*\*([\s\S]*?)\*\//, "").trimStart(); +} + +function titleFromSlug(slug: string): string { + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function addDocEntry(entries: HelpEntry[], docsBySlug: Map, slug: string, depth: number) { + const doc = docsBySlug.get(slug); + if (!doc) return false; + entries.push({kind: "page", title: doc.title, slug, depth, selectable: true}); + return true; +} + +export function loadHelpDocs(docsDir: string): HelpDocs { + const docsBySlug = new Map(); + const files = fs + .readdirSync(docsDir) + .filter((file) => file.endsWith(".recho.js")) + .sort(); + + for (const file of files) { + const slug = file.replace(/\.recho\.js$/, ""); + const raw = fs.readFileSync(path.join(docsDir, file), "utf8"); + const meta = parseJSMeta(raw); + const content = removeJSMeta(raw); + docsBySlug.set(slug, { + slug, + title: meta.title || titleFromSlug(slug), + content, + lines: content.split("\n"), + }); + } + + const entries: HelpEntry[] = []; + const included = new Set(); + + for (const item of docsNavConfig as DocsNavItem[]) { + if (item.type === "page") { + if (addDocEntry(entries, docsBySlug, item.slug, 0)) included.add(item.slug); + continue; + } + + const groupDoc = item.slug ? docsBySlug.get(item.slug) : null; + entries.push({ + kind: "group", + title: item.title, + slug: groupDoc ? item.slug || null : null, + depth: 0, + selectable: Boolean(groupDoc), + }); + if (groupDoc && item.slug) included.add(item.slug); + for (const child of item.items) { + if (addDocEntry(entries, docsBySlug, child.slug, 1)) included.add(child.slug); + } + } + + for (const slug of [...docsBySlug.keys()].sort()) { + if (included.has(slug)) continue; + addDocEntry(entries, docsBySlug, slug, 0); + } + + return {entries, docsBySlug}; +} diff --git a/terminal/highlight.ts b/terminal/highlight.ts new file mode 100644 index 0000000..63ecc25 --- /dev/null +++ b/terminal/highlight.ts @@ -0,0 +1,215 @@ +// Lightweight JavaScript syntax highlighting that emits styled spans for +// each line. Output line is treated specially: if the line begins with the +// runtime's output or error prefix it's colored as such. Everything else +// gets a single regex-based pass over keyword/number/string/comment. + +import {fg, italic, reset, bold} from "./screen.ts"; +import {OUTPUT_PREFIX, ERROR_PREFIX} from "../runtime/output.js"; + +// Theme palette (256-color indices). +const C = { + fg: 252, + comment: 244, + keyword: 175, + string: 150, + number: 222, + fn: 110, + punct: 246, + output: 114, // green + error: 203, // red + output_dim: 65, + ln: 240, + lnActive: 252, + border: 240, + borderHi: 250, + title: 252, + status: 250, + dimText: 245, + panelBg: 235, + selBg: 24, + cursorLineBg: 236, + marker: 110, + hot: 209, +}; + +export const COLORS = C; +export type ColorName = keyof typeof C; + +const KEYWORDS = new Set([ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "if", + "import", + "in", + "instanceof", + "let", + "new", + "null", + "of", + "return", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "undefined", + "var", + "void", + "while", + "with", + "yield", + "async", + "await", +]); + +export type HighlightState = {inBlockComment: boolean}; + +const INITIAL_STATE: HighlightState = {inBlockComment: false}; +const STRING_RE = /^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^\\`])*`)/; +const NUMBER_RE = /^\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b/i; +const IDENT_RE = /^[A-Za-z_$][\w$]*/; +const PUNCT_RE = /^[{}()[\];,.<>:?!+\-*/%=&|^~]/; + +function styleComment(text: string): string { + return fg(C.comment) + italic + text + reset + fg(C.fg); +} + +function styleToken(tok: string, line: string, index: number): string { + let style: string; + if (tok[0] === '"' || tok[0] === "'" || tok[0] === "`") style = fg(C.string); + else if (/^\d/.test(tok)) style = fg(C.number); + else if (/^[A-Za-z_$]/.test(tok) && KEYWORDS.has(tok)) style = fg(C.keyword) + bold; + else if (/^[A-Za-z_$]/.test(tok)) { + const next = line[index + tok.length]; + if (next === "(") style = fg(C.fn); + else style = fg(C.fg); + } else style = fg(C.punct); + + return style + tok + reset + fg(C.fg); +} + +export function nextHighlightState(line: string, state: HighlightState = INITIAL_STATE): HighlightState { + let inBlockComment = state.inBlockComment; + let quote: string | null = null; + let escaped = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + const next = line[i + 1]; + + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + i++; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === quote) { + quote = null; + } + continue; + } + + if (ch === "/" && next === "/") break; + if (ch === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") quote = ch; + } + + return {inBlockComment}; +} + +// Highlight a single line of source (without the runtime output prefix). +// Returns a styled string (length = visible chars equal to `line`). +export function highlightLine(line: string, state: HighlightState = INITIAL_STATE): string { + // Comment? Whole-line color. + const trimmed = line.trimStart(); + if (trimmed.startsWith(OUTPUT_PREFIX) || trimmed.startsWith("//➜")) { + return fg(C.output) + line + reset; + } + if (trimmed.startsWith(ERROR_PREFIX) || trimmed.startsWith("//✗")) { + return fg(C.error) + line + reset; + } + if (trimmed.startsWith("//")) { + return fg(C.comment) + italic + line + reset; + } + + let out = fg(C.fg); + let i = 0; + let inBlockComment = state.inBlockComment; + + while (i < line.length) { + if (inBlockComment) { + const end = line.indexOf("*/", i); + if (end === -1) { + out += styleComment(line.slice(i)); + break; + } + out += styleComment(line.slice(i, end + 2)); + i = end + 2; + inBlockComment = false; + continue; + } + + if (line.startsWith("//", i)) { + out += styleComment(line.slice(i)); + break; + } + if (line.startsWith("/*", i)) { + const end = line.indexOf("*/", i + 2); + if (end === -1) { + out += styleComment(line.slice(i)); + break; + } + out += styleComment(line.slice(i, end + 2)); + i = end + 2; + continue; + } + + const rest = line.slice(i); + const token = + rest.match(STRING_RE)?.[0] ?? + rest.match(NUMBER_RE)?.[0] ?? + rest.match(IDENT_RE)?.[0] ?? + rest.match(PUNCT_RE)?.[0]; + + if (token) { + out += styleToken(token, line, i); + i += token.length; + } else { + out += line[i]; + i++; + } + } + + out += reset; + return out; +} diff --git a/terminal/screen.ts b/terminal/screen.ts new file mode 100644 index 0000000..d62beb0 --- /dev/null +++ b/terminal/screen.ts @@ -0,0 +1,409 @@ +// Low-level terminal IO: alternate-screen, raw mode, mouse, key parsing, +// styled writing and a tiny diff-aware grid renderer. +// +// Everything is encoded as ANSI/CSI/SGR escape sequences and written to +// stdout. Input is read raw from stdin and parsed into key/mouse events. + +const ESC = "\x1b"; +export const CSI = ESC + "["; + +export const enterAlt = CSI + "?1049h"; +export const leaveAlt = CSI + "?1049l"; +export const hideCursor = CSI + "?25l"; +export const showCursor = CSI + "?25h"; +export const clearScreen = CSI + "2J"; +export const home = CSI + "H"; + +// SGR mouse with extended coordinates + button-press tracking + drag tracking. +export const enableMouse = CSI + "?1000h" + CSI + "?1002h" + CSI + "?1006h"; +export const disableMouse = CSI + "?1006l" + CSI + "?1002l" + CSI + "?1000l"; + +export const reset = CSI + "0m"; + +export type KeyEvent = { + type: "key"; + name: string; + ch: string; + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +export type MouseKind = "press" | "release" | "drag" | "wheel-up" | "wheel-down"; + +export type MouseEvent = { + type: "mouse"; + kind: MouseKind; + button: number; + row: number; + col: number; + shift?: boolean; + meta?: boolean; + ctrl?: boolean; +}; + +export type TextEvent = {type: "text"; text: string}; +export type InputEvent = KeyEvent | MouseEvent | TextEvent; + +type GridCell = {ch: string; style: string}; + +export function moveTo(row: number, col: number): string { + return CSI + (row + 1) + ";" + (col + 1) + "H"; +} + +export function fg(n: number): string { + return CSI + "38;5;" + n + "m"; +} +export function bg(n: number): string { + return CSI + "48;5;" + n + "m"; +} +export function rgbFg(r: number, g: number, b: number): string { + return CSI + "38;2;" + r + ";" + g + ";" + b + "m"; +} +export function rgbBg(r: number, g: number, b: number): string { + return CSI + "48;2;" + r + ";" + g + ";" + b + "m"; +} +export const bold = CSI + "1m"; +export const dim = CSI + "2m"; +export const italic = CSI + "3m"; +export const underline = CSI + "4m"; +export const inverse = CSI + "7m"; +export const noBold = CSI + "22m"; +export const noItalic = CSI + "23m"; +export const noUnderline = CSI + "24m"; +export const noInverse = CSI + "27m"; + +// Strip SGR codes for length measurement. +const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g; +export function stripAnsi(s: string): string { + return s.replace(ANSI_RE, ""); +} +export function visibleLength(s: string): number { + return stripAnsi(s).length; +} + +// Pad a styled string to width n using spaces (preserving ANSI). +export function padToWidth(s: string, n: number): string { + const v = visibleLength(s); + if (v >= n) return s; + return s + " ".repeat(n - v); +} + +// Truncate styled string to visible width n (drops trailing chars; ignores +// the rare case of mid-escape splitting which is not produced by us). +export function truncateToWidth(s: string, n: number): string { + if (visibleLength(s) <= n) return s; + let out = ""; + let used = 0; + let i = 0; + while (i < s.length && used < n) { + if (s[i] === "\x1b" && s[i + 1] === "[") { + const end = s.indexOf("m", i); + if (end === -1) break; + out += s.slice(i, end + 1); + i = end + 1; + continue; + } + out += s[i]; + used++; + i++; + } + return out + reset; +} + +// --------------------------------------------------------------------------- +// Input parsing: keyboard + mouse events. + +// A key event has: {type: "key", name, ch, ctrl, alt, shift} +// A mouse event has: {type: "mouse", action, button, row, col, mods} +// A resize event: {type: "resize", rows, cols} +// A paste event: {type: "paste", text} + +// Control bytes with a friendly name. Both CR (13) and LF (10) map to +// `enter` so multi-line paste and Enter both work across terminals — that +// means we can't bind ^J / ^M, which is fine. +const NAMED = new Map([ + [13, "enter"], + [10, "enter"], + [9, "tab"], + [127, "backspace"], + [8, "backspace"], + [27, "escape"], + [32, "space"], +]); + +export function parseInput(buf: string): InputEvent[] { + // Returns array of events and the number of bytes consumed (some bytes + // may remain if a sequence is partial; for simplicity we always consume + // everything we recognized). + const events: InputEvent[] = []; + let i = 0; + const s = buf; + while (i < s.length) { + const ch = s.charCodeAt(i); + if (ch === 0x1b) { + // Escape sequences + if (s[i + 1] === "[") { + // CSI + const j = i + 2; + // SGR mouse: ESC[= s.length) break; + const params = s + .slice(j + 1, k) + .split(";") + .map(Number); + const action = s[k] === "M" ? "press" : "release"; + const [code = 0, col = 1, row = 1] = params; + // code bits: low 2 = button (0=left,1=mid,2=right,3=release-old) + // bit 5 (32) = motion, bit 6 (64) = wheel + const button = code & 3; + const motion = (code & 32) !== 0; + const wheel = (code & 64) !== 0; + const shift = (code & 4) !== 0; + const meta = (code & 8) !== 0; + const ctrl = (code & 16) !== 0; + let kind: MouseKind = "press"; + if (action === "release") kind = "release"; + else if (motion) kind = "drag"; + if (wheel) kind = button === 0 ? "wheel-up" : "wheel-down"; + events.push({ + type: "mouse", + kind, + button, + row: row - 1, + col: col - 1, + shift, + meta, + ctrl, + }); + i = k + 1; + continue; + } + // Extract intermediate + let k = j; + while (k < s.length && s.charCodeAt(k) >= 0x30 && s.charCodeAt(k) <= 0x3f) k++; + const finalChar = s[k]; + const params = s.slice(j, k); + const seq = "[" + params + finalChar; + const ev = csiToEvent(seq); + if (ev) events.push(ev); + i = k + 1; + continue; + } + if (s[i + 1] === "O") { + // SS3 (function keys, sometimes home/end) + const code = s[i + 2]; + let name: string | null = null; + if (code === "P") name = "f1"; + else if (code === "Q") name = "f2"; + else if (code === "R") name = "f3"; + else if (code === "S") name = "f4"; + else if (code === "H") name = "home"; + else if (code === "F") name = "end"; + if (name) events.push({type: "key", name, ch: "", ctrl: false, alt: false, shift: false}); + i += 3; + continue; + } + // Alt+key + if (i + 1 < s.length) { + const next = s.charCodeAt(i + 1); + if (next >= 0x20 && next < 0x7f) { + events.push({ + type: "key", + name: NAMED.get(next) || s[i + 1], + ch: s[i + 1], + ctrl: false, + alt: true, + shift: false, + }); + i += 2; + continue; + } + } + // Lone escape + events.push({type: "key", name: "escape", ch: "", ctrl: false, alt: false, shift: false}); + i++; + continue; + } + // Control characters + if (ch < 0x20 || ch === 0x7f) { + const named = NAMED.get(ch); + if (named) { + events.push({type: "key", name: named, ch: "", ctrl: false, alt: false, shift: false}); + } else { + // Ctrl+letter: Ctrl+A=1 ... Ctrl+Z=26 + const letter = String.fromCharCode(ch + 96); + events.push({type: "key", name: letter, ch: letter, ctrl: true, alt: false, shift: false}); + } + i++; + continue; + } + // Printable text — group consecutive bytes into a single text event + let j = i; + while (j < s.length) { + const c = s.charCodeAt(j); + if (c < 0x20 || c === 0x7f || c === 0x1b) break; + j++; + } + events.push({type: "text", text: s.slice(i, j)}); + i = j; + } + return events; +} + +function csiToEvent(seq: string): KeyEvent | null { + // seq is everything after the leading ESC, e.g. "[A" or "[1;5C" + // Common sequences + const map: Record = { + "[A": "up", + "[B": "down", + "[C": "right", + "[D": "left", + "[H": "home", + "[F": "end", + "[2~": "insert", + "[3~": "delete", + "[5~": "pageup", + "[6~": "pagedown", + "[1~": "home", + "[4~": "end", + "[7~": "home", + "[8~": "end", + "[Z": "shift-tab", + }; + if (map[seq]) { + return {type: "key", name: map[seq], ch: "", ctrl: false, alt: false, shift: seq === "[Z"}; + } + // Modifier sequences like "[1;5C" (Ctrl+Right). Strip "1;" prefix. + const m = seq.match(/^\[(\d+);(\d+)([A-Za-z~])$/); + if (m) { + const code = parseInt(m[2] ?? "0", 10) - 1; + const shift = (code & 1) !== 0; + const alt = (code & 2) !== 0; + const ctrl = (code & 4) !== 0; + const sub = "[" + (m[3] === "~" ? m[1] + "~" : m[3]); + const baseMap: Record = { + "[A": "up", + "[B": "down", + "[C": "right", + "[D": "left", + "[H": "home", + "[F": "end", + "[3~": "delete", + "[1~": "home", + "[4~": "end", + "[5~": "pageup", + "[6~": "pagedown", + }; + const name = baseMap[sub]; + if (name) return {type: "key", name, ch: "", ctrl, alt, shift}; + } + return null; +} + +// --------------------------------------------------------------------------- +// Grid: a virtual screen made of style+char per cell. We diff against the +// previously emitted grid and only repaint cells that changed. + +export class Grid { + rows: number; + cols: number; + cells: GridCell[]; + + constructor(rows: number, cols: number) { + this.rows = rows; + this.cols = cols; + this.cells = new Array(rows * cols); + for (let i = 0; i < this.cells.length; i++) this.cells[i] = {ch: " ", style: ""}; + } + + resize(rows: number, cols: number) { + this.rows = rows; + this.cols = cols; + this.cells = new Array(rows * cols); + for (let i = 0; i < this.cells.length; i++) this.cells[i] = {ch: " ", style: ""}; + } + + clear() { + for (let i = 0; i < this.cells.length; i++) { + this.cells[i].ch = " "; + this.cells[i].style = ""; + } + } + + setCell(row: number, col: number, ch: string, style: string) { + if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) return; + const cell = this.cells[row * this.cols + col]; + cell.ch = ch; + cell.style = style; + } + + // Write a styled string (already containing SGR codes) starting at (row, + // col), clipping at the right edge. The provided `style` is the style at + // entry; SGR escapes inside `s` may modify the live style. + writeStyled(row: number, col: number, s: string, baseStyle = ""): number { + if (row < 0 || row >= this.rows) return col; + let style = baseStyle; + let i = 0; + let c = col; + while (i < s.length && c < this.cols) { + if (s[i] === "\x1b" && s[i + 1] === "[") { + const end = s.indexOf("m", i); + if (end === -1) break; + style += s.slice(i, end + 1); + i = end + 1; + continue; + } + // Skip combining \r etc. + const ch = s[i]; + if (ch === "\n" || ch === "\r") { + i++; + continue; + } + this.setCell(row, c, ch, style); + c++; + i++; + } + return c; + } + + // Fill a row range with the given (plain) char and style. + fillRect(row0: number, col0: number, row1: number, col1: number, ch: string, style: string) { + for (let r = row0; r < row1; r++) { + for (let c = col0; c < col1; c++) { + this.setCell(r, c, ch, style); + } + } + } +} + +// Render `next` to stdout, computing a minimal diff from `prev`. +export function renderDiff(prev: Grid | null, next: Grid, write: (output: string) => void) { + let out = ""; + let curStyle = ""; + let curRow = -1; + let curCol = -1; + for (let r = 0; r < next.rows; r++) { + for (let c = 0; c < next.cols; c++) { + const i = r * next.cols + c; + const a = prev && prev.rows === next.rows && prev.cols === next.cols ? prev.cells[i] : null; + const b = next.cells[i]; + if (a && a.ch === b.ch && a.style === b.style) continue; + if (curRow !== r || curCol !== c) { + out += moveTo(r, c); + curRow = r; + curCol = c; + } + if (b.style !== curStyle) { + out += reset + b.style; + curStyle = b.style; + } + out += b.ch; + curCol++; + } + } + if (out) write(reset + out + reset); +} diff --git a/terminal/workerRuntime.ts b/terminal/workerRuntime.ts new file mode 100644 index 0000000..9bacc2a --- /dev/null +++ b/terminal/workerRuntime.ts @@ -0,0 +1,203 @@ +// Main-thread proxy for the runtime worker. Wraps a Worker so the rest of +// the TUI can stay near the original `createRuntime` shape: `setCode`, +// `setIsRunning`, `run`, `onChanges`, `onError`, `destroy`. Adds: +// +// onConsole(cb) — receives captured console output from the worker +// onHung(cb) — fires when the heartbeat watchdog trips +// onExit(cb) — fires when the underlying Worker exits +// isAlive() — false once the worker has exited and not been respawned +// terminate() — kill the worker without spawning a new one +// restart(code) — kill + respawn fresh with the given source + +import {Worker} from "node:worker_threads"; +import {fileURLToPath} from "node:url"; +import path from "node:path"; +import {dispatch as d3Dispatch} from "d3-dispatch"; +import type {ChangeSpec} from "./buffer.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WORKER_PATH = path.resolve(__dirname, "..", "runtime", "worker.ts"); + +function workerExecArgv(): string[] { + return process.execArgv.filter((arg) => !arg.startsWith("--input-type")); +} + +export type WorkerRuntimeOptions = { + cellTimeoutMs?: number; + heartbeatGraceMs?: number; +}; + +export type RuntimeErrorEvent = {error: Error & {code?: unknown}; source?: string}; +export type RuntimeConsoleEvent = {level: string; text: string}; +export type RuntimeChangesEvent = {changes: ChangeSpec[]; effects: []}; +export type RuntimeHungEvent = {sinceHeartbeatMs: number}; +export type RuntimeExitEvent = {code: number | null}; + +type WorkerMessage = + | {type: "heartbeat"} + | {type: "online"} + | {type: "ready"} + | {type: "changes"; changes: ChangeSpec[]} + | {type: "error"; error?: {message?: string; name?: string; stack?: string; code?: unknown}; source?: string} + | {type: "console"; level: string; text: string}; + +export function createWorkerRuntime(initialCode: string, options: WorkerRuntimeOptions = {}) { + const dispatcher = d3Dispatch("changes", "error", "console", "ready", "online", "hung", "exit"); + + // Watchdog: if the worker stops sending heartbeats for this long, we + // consider it hung. The `cellTimeoutMs` (default 1000) catches sync + // infinite loops first; this catches the async cases (`while (true) + // await ...`, recursive promise chains, blocking native calls) that + // vm.timeout can't. + const heartbeatGraceMs = options.heartbeatGraceMs ?? 3000; + const cellTimeoutMs = options.cellTimeoutMs ?? 1000; + + let worker: Worker | null = null; + let alive = false; + let hung = false; + let isRunning = false; + let booting = false; + let lastHeartbeat = Date.now(); + let lastCode = initialCode; + let watchdog: NodeJS.Timeout | null = null; + + function spawn(code: string) { + teardownWorker(); + hung = false; + alive = true; + lastHeartbeat = Date.now(); + lastCode = code; + booting = true; + worker = new Worker(WORKER_PATH, { + // Do not inherit eval-only flags such as --input-type=module; workers + // load a real file and Node rejects those flags for file entry points. + execArgv: workerExecArgv(), + stdout: false, + stderr: false, + }); + worker.on("message", onMessage); + worker.on("error", (e) => + dispatcher.call("console", null, {level: "error", text: `[worker] ${e?.stack || String(e)}`}), + ); + worker.on("exit", (code) => { + alive = false; + booting = false; + worker = null; + dispatcher.call("exit", null, {code}); + }); + worker.postMessage({type: "init", code, options: {cellTimeoutMs}}); + } + + function teardownWorker() { + if (!worker) return; + try { + worker.removeAllListeners(); + worker.terminate(); + } catch { + /* ignore */ + } + worker = null; + alive = false; + booting = false; + } + + function onMessage(msg: WorkerMessage) { + if (!msg || typeof msg !== "object") return; + switch (msg.type) { + case "heartbeat": + lastHeartbeat = Date.now(); + break; + case "online": + dispatcher.call("online", null, {}); + break; + case "ready": + booting = false; + if (isRunning) worker?.postMessage({type: "setIsRunning", value: true}); + dispatcher.call("ready", null, {}); + break; + case "changes": + // Effects are dropped at the worker boundary — only `changes` matter + // to a non-CodeMirror frontend. + dispatcher.call("changes", null, {changes: msg.changes, effects: []}); + break; + case "error": { + const err: Error & {code?: unknown} = new Error(msg.error?.message || "runtime error"); + if (msg.error?.name) err.name = msg.error.name; + if (msg.error?.stack) err.stack = msg.error.stack; + if (msg.error?.code) err.code = msg.error.code; + dispatcher.call("error", null, {error: err, source: msg.source}); + break; + } + case "console": + dispatcher.call("console", null, {level: msg.level, text: msg.text}); + break; + } + } + + function checkWatchdog() { + if (!alive || hung) return; + const gap = Date.now() - lastHeartbeat; + if (gap > heartbeatGraceMs) { + hung = true; + dispatcher.call("hung", null, {sinceHeartbeatMs: gap}); + } + } + + watchdog = setInterval(checkWatchdog, 250); + watchdog.unref?.(); + + spawn(initialCode); + + return { + setCode(code: string) { + lastCode = code; + worker?.postMessage({type: "setCode", code}); + }, + setIsRunning(value: boolean) { + isRunning = !!value; + worker?.postMessage({type: "setIsRunning", value: !!value}); + }, + run() { + if (!alive || !worker || hung) { + spawn(lastCode); + isRunning = true; + return; + } + isRunning = true; + worker?.postMessage({type: "run"}); + }, + isRunning: () => isRunning, + isAlive: () => alive && !hung, + isHung: () => hung, + terminate() { + teardownWorker(); + isRunning = false; + }, + restart(code?: string) { + spawn(code ?? lastCode); + isRunning = true; + }, + destroy() { + if (watchdog) clearInterval(watchdog); + teardownWorker(); + isRunning = false; + }, + onChanges: (cb: ((event: RuntimeChangesEvent) => void) | null) => + dispatcher.on("changes", cb as unknown as ((...args: unknown[]) => void) | null), + onError: (cb: ((event: RuntimeErrorEvent) => void) | null) => + dispatcher.on("error", cb as unknown as ((...args: unknown[]) => void) | null), + onConsole: (cb: ((event: RuntimeConsoleEvent) => void) | null) => + dispatcher.on("console", cb as unknown as ((...args: unknown[]) => void) | null), + onReady: (cb: (() => void) | null) => + dispatcher.on("ready", cb as unknown as ((...args: unknown[]) => void) | null), + onOnline: (cb: (() => void) | null) => + dispatcher.on("online", cb as unknown as ((...args: unknown[]) => void) | null), + onHung: (cb: ((event: RuntimeHungEvent) => void) | null) => + dispatcher.on("hung", cb as unknown as ((...args: unknown[]) => void) | null), + onExit: (cb: ((event: RuntimeExitEvent) => void) | null) => + dispatcher.on("exit", cb as unknown as ((...args: unknown[]) => void) | null), + isBooting: () => booting, + }; +} + +export type WorkerRuntime = ReturnType; diff --git a/test/js/matrix-rain.js b/test/js/matrix-rain.js index c5c6f6e..61fd593 100644 --- a/test/js/matrix-rain.js +++ b/test/js/matrix-rain.js @@ -53,4 +53,4 @@ function randomChar() { const frame = recho.interval(1000 / 15); -const d3 = recho.require("d3");`; +const d3 = recho.require("d3-array", "d3-random");`; diff --git a/test/js/random-histogram.js b/test/js/random-histogram.js index d09386d..31f3936 100644 --- a/test/js/random-histogram.js +++ b/test/js/random-histogram.js @@ -1,4 +1,4 @@ -export const randomHistogram = `const d3 = await recho.require("d3"); +export const randomHistogram = `const d3 = await recho.require("d3-array", "d3-random"); const count = 200; const width = 50; diff --git a/test/stdlib.spec.js b/test/stdlib.spec.js index af1abff..b915a4d 100644 --- a/test/stdlib.spec.js +++ b/test/stdlib.spec.js @@ -1,6 +1,10 @@ -import {it, expect} from "vitest"; +import {afterEach, it, expect, vi} from "vitest"; import * as stdlib from "../runtime/stdlib/index.js"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + it("should export expected functions from stdlib", () => { expect(stdlib.require).toBeDefined(); expect(stdlib.now).toBeDefined(); @@ -10,3 +14,14 @@ it("should export expected functions from stdlib", () => { expect(stdlib.radio).toBeDefined(); expect(stdlib.state).toBeDefined(); }); + +it("should require npm modules without a document object", async () => { + vi.stubGlobal("document", undefined); + + const d3 = await stdlib.require("d3-array", "d3-random"); + expect(d3.range(3)).toEqual([0, 1, 2]); + expect(typeof d3.randomInt).toBe("function"); + + const _ = await stdlib.require("lodash"); + expect(_.times(3, String)).toEqual(["0", "1", "2"]); +}); diff --git a/test/terminalFileActions.spec.js b/test/terminalFileActions.spec.js new file mode 100644 index 0000000..18b21bc --- /dev/null +++ b/test/terminalFileActions.spec.js @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {afterEach, describe, expect, it} from "vitest"; +import {App} from "../terminal/app.ts"; + +const tmpDirs = []; + +function makeTmpDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "recho-terminal-")); + tmpDirs.push(dir); + return dir; +} + +function makeApp(initialPath = null, initialCode = "echo(1);") { + const app = new App({initialPath, initialCode, examplesDir: null}); + app.initRuntime = () => {}; + return app; +} + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe("terminal file actions", () => { + it("creates a new empty buffer", () => { + const app = makeApp("/tmp/old.recho.js", "echo(1);"); + + app.newFile(); + + expect(app.path).toBe(null); + expect(app.buffer.text).toBe(""); + expect(app.scrollY).toBe(0); + expect(app.scrollX).toBe(0); + }); + + it("renames the current file and keeps the current buffer contents", () => { + const dir = makeTmpDir(); + const oldPath = path.join(dir, "old.recho.js"); + const newPath = path.join(dir, "new.recho.js"); + fs.writeFileSync(oldPath, "echo('old');", "utf8"); + const app = makeApp(oldPath, "echo('edited');"); + + app.renamePrompt(); + app.activeModal.onSubmit(newPath); + + expect(fs.existsSync(oldPath)).toBe(false); + expect(fs.readFileSync(newPath, "utf8")).toBe("echo('edited');"); + expect(app.path).toBe(newPath); + }); +}); diff --git a/test/terminalHelp.spec.js b/test/terminalHelp.spec.js new file mode 100644 index 0000000..5a08663 --- /dev/null +++ b/test/terminalHelp.spec.js @@ -0,0 +1,28 @@ +import path from "node:path"; +import {describe, expect, it} from "vitest"; +import {App} from "../terminal/app.ts"; + +const docsDir = path.join(process.cwd(), "app", "docs"); + +describe("terminal help", () => { + it("opens tutorials from the docs outline", () => { + const app = new App({initialPath: null, initialCode: "", examplesDir: null, docsDir}); + + app.openHelp(); + + expect(app.activeModal.type).toBe("help"); + expect(app.activeModal.helpSlug).toBe("introduction"); + expect(app.activeModal.help.entries.some((entry) => entry.title === "Features")).toBe(true); + expect(app.activeModal.help.docsBySlug.get("getting-started")?.title).toBe("Getting Started"); + }); + + it("selects another tutorial from the outline", () => { + const app = new App({initialPath: null, initialCode: "", examplesDir: null, docsDir}); + app.openHelp(); + + app.moveHelpSelection(1); + + expect(app.activeModal.helpSlug).toBe("getting-started"); + expect(app.activeModal.helpScroll).toBe(0); + }); +}); diff --git a/test/terminalHighlight.spec.js b/test/terminalHighlight.spec.js new file mode 100644 index 0000000..4c09f52 --- /dev/null +++ b/test/terminalHighlight.spec.js @@ -0,0 +1,52 @@ +import {describe, expect, it} from "vitest"; +import {OUTPUT_PREFIX} from "../runtime/output.js"; +import {highlightLine, nextHighlightState, COLORS} from "../terminal/highlight.ts"; +import {App} from "../terminal/app.ts"; +import {fg, italic, bold, stripAnsi, Grid} from "../terminal/screen.ts"; + +const keywordStyle = fg(COLORS.keyword) + bold; + +describe("terminal highlighting", () => { + it("does not italicize output comments", () => { + const line = `${OUTPUT_PREFIX} for in`; + const styled = highlightLine(line); + + expect(stripAnsi(styled)).toBe(line); + expect(styled).toContain(fg(COLORS.output)); + expect(styled).not.toContain(italic); + expect(styled).not.toContain(keywordStyle); + }); + + it("does not highlight keywords inside line comments", () => { + const line = "// for in"; + const styled = highlightLine(line); + + expect(stripAnsi(styled)).toBe(line); + expect(styled).toContain(italic); + expect(styled).not.toContain(keywordStyle); + }); + + it("keeps keywords inside block comment bodies styled as comments", () => { + const state = nextHighlightState("/**"); + const line = " * for in"; + const styled = highlightLine(line, state); + + expect(stripAnsi(styled)).toBe(line); + expect(styled).toContain(italic); + expect(styled).not.toContain(keywordStyle); + }); + + it("keeps the line-number gutter intact while horizontally scrolled", () => { + const app = new App({initialPath: null, initialCode: "const answer = 42;", examplesDir: null}); + app.cols = 14; + app.rows = 6; + app.scrollX = 2; + app.grid = new Grid(app.rows, app.cols); + + app.drawEditor(); + + const row = 2; + const chars = app.grid.cells.slice(row * app.cols, row * app.cols + 6).map((cell) => cell.ch); + expect(chars.join("")).toBe(" 1 n"); + }); +}); diff --git a/test/terminalScrollbar.spec.js b/test/terminalScrollbar.spec.js new file mode 100644 index 0000000..f6abc29 --- /dev/null +++ b/test/terminalScrollbar.spec.js @@ -0,0 +1,43 @@ +import {describe, expect, it} from "vitest"; +import {App} from "../terminal/app.ts"; + +function makeApp(lineCount = 30) { + const initialCode = Array.from({length: lineCount}, (_, i) => `echo(${i});`).join("\n"); + const app = new App({initialPath: null, initialCode, examplesDir: null}); + app.cols = 20; + app.rows = 10; + return app; +} + +describe("terminal scrollbar", () => { + it("reserves the rightmost column from the editor box", () => { + const app = makeApp(); + expect(app.scrollbarCol()).toBe(19); + expect(app.editorBox().right).toBe(19); + }); + + it("maps thumb rows to scroll progress", () => { + const app = makeApp(20); + const state = app.scrollbarState(); + + expect(state.trackTop).toBe(2); + expect(state.trackHeight).toBe(6); + expect(state.thumbHeight).toBe(1); + expect(state.maxScroll).toBe(14); + + app.scrollToScrollbarRow(state.trackBottom - state.thumbHeight); + expect(app.scrollY).toBe(14); + }); + + it("drags the scrollbar thumb with the mouse", () => { + const app = makeApp(20); + const state = app.scrollbarState(); + + app.handleMouse({type: "mouse", kind: "press", button: 0, row: state.thumbTop, col: app.scrollbarCol()}); + app.handleMouse({type: "mouse", kind: "drag", button: 0, row: state.trackBottom - 1, col: app.scrollbarCol()}); + app.handleMouse({type: "mouse", kind: "release", button: 0, row: state.trackBottom - 1, col: app.scrollbarCol()}); + + expect(app.scrollY).toBe(state.maxScroll); + expect(app.scrollbarDragging).toBe(false); + }); +}); diff --git a/test/workerRuntime.spec.js b/test/workerRuntime.spec.js new file mode 100644 index 0000000..280f11e --- /dev/null +++ b/test/workerRuntime.spec.js @@ -0,0 +1,58 @@ +import {describe, expect, it} from "vitest"; +import {OUTPUT_PREFIX} from "../runtime/output.js"; +import {createWorkerRuntime} from "../terminal/workerRuntime.ts"; + +function waitForChanges(runtime, predicate = () => true) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + runtime.onChanges(null); + reject(new Error("timed out waiting for worker runtime changes")); + }, 5000); + + runtime.onChanges((event) => { + if (!predicate(event)) return; + clearTimeout(timer); + runtime.onChanges(null); + resolve(event); + }); + }); +} + +function hasOutput(event, value) { + return event.changes?.some((change) => change.insert?.includes(`${OUTPUT_PREFIX} ${value}`)); +} + +describe("worker runtime", () => { + it("emits inline output changes from a worker thread", async () => { + const runtime = createWorkerRuntime("echo(1);", {cellTimeoutMs: 500, heartbeatGraceMs: 1000}); + try { + const changes = waitForChanges(runtime, (event) => hasOutput(event, 1)); + runtime.run(); + expect(hasOutput(await changes, 1)).toBe(true); + } finally { + runtime.destroy(); + } + }); + + it("respawns on run after being terminated", async () => { + const runtime = createWorkerRuntime("echo(1);", {cellTimeoutMs: 500, heartbeatGraceMs: 1000}); + try { + const initialChanges = waitForChanges(runtime, (event) => hasOutput(event, 1)); + runtime.run(); + await initialChanges; + + runtime.terminate(); + expect(runtime.isAlive()).toBe(false); + expect(runtime.isRunning()).toBe(false); + + runtime.setCode("echo(2);"); + const restartedChanges = waitForChanges(runtime, (event) => hasOutput(event, 2)); + runtime.run(); + + expect(runtime.isAlive()).toBe(true); + expect(hasOutput(await restartedChanges, 2)).toBe(true); + } finally { + runtime.destroy(); + } + }); +}); diff --git a/types/d3-dispatch.d.ts b/types/d3-dispatch.d.ts new file mode 100644 index 0000000..e1a2eb8 --- /dev/null +++ b/types/d3-dispatch.d.ts @@ -0,0 +1,6 @@ +declare module "d3-dispatch" { + export function dispatch(...types: string[]): { + call(type: string, that?: unknown, ...args: unknown[]): void; + on(type: string, callback: ((...args: unknown[]) => void) | null): unknown; + }; +}