From 6845606846736327ae1ac0322dd749d91b044eba Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:04:19 +0800 Subject: [PATCH 1/8] Add terminal-mode TUI for Recho Notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the existing reactive runtime under a new full-screen terminal UI: line-numbered editor with syntax highlighting, mouse support (click to focus, drag to select, wheel to scroll), examples picker, and inline //➜ output comments — feature parity with the web editor for echo-based notebooks. - terminal/screen.js: raw-mode I/O, SGR mouse + key parsing, diffing grid - terminal/buffer.js: text buffer with cursor-aware change-spec mapping - terminal/highlight.js: regex-based JS highlighting with output/error tints - terminal/app.js: layout, render loop, runtime wiring, modals - terminal/cli.js: executable entry point (bin: recho, script: pnpm tui) Two minor adjustments to keep the runtime importable from plain Node 24: strip TS parameter properties from BlockMetadata.ts and add explicit .js extensions in runtime/controls/index.js. All 114 existing tests still pass. Co-Authored-By: Claude Opus 4.7 --- editor/blocks/BlockMetadata.ts | 29 +- package.json | 4 + runtime/controls/index.js | 6 +- terminal/app.js | 883 +++++++++++++++++++++++++++++++++ terminal/buffer.js | 223 +++++++++ terminal/cli.js | 84 ++++ terminal/highlight.js | 140 ++++++ terminal/screen.js | 373 ++++++++++++++ 8 files changed, 1732 insertions(+), 10 deletions(-) create mode 100644 terminal/app.js create mode 100644 terminal/buffer.js create mode 100755 terminal/cli.js create mode 100644 terminal/highlight.js create mode 100644 terminal/screen.js 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/package.json b/package.json index 96882f8..cd45378 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,12 @@ "computational-art" ], "type": "module", + "bin": { + "recho": "./terminal/cli.js" + }, "scripts": { "dev": "vite", + "tui": "node terminal/cli.js", "app:dev": "next dev", "app:build": "next build", "app:start": "next start", 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/terminal/app.js b/terminal/app.js new file mode 100644 index 0000000..51db413 --- /dev/null +++ b/terminal/app.js @@ -0,0 +1,883 @@ +// 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 {createRuntime} from "../runtime/index.js"; +import {OUTPUT_PREFIX, ERROR_PREFIX} from "../runtime/output.js"; +import {Buffer} from "./buffer.js"; +import {highlightLine, COLORS} from "./highlight.js"; +import * as scr from "./screen.js"; +import {fg, bg, rgbBg, rgbFg, dim, italic, reset, bold, inverse, moveTo, padToWidth, truncateToWidth, visibleLength} from "./screen.js"; + +const HEADER_ROWS = 2; +const FOOTER_ROWS = 2; +const GUTTER = 5; // " 123 " + +const HELP_LINES = [ + "Welcome to Recho · the reactive notebook in your terminal.", + "Type code; press ^S to run it. Output appears inline as //➜ comments.", +]; + +export class App { + constructor({initialPath, initialCode, examplesDir}) { + this.path = initialPath; + this.examplesDir = examplesDir; + this.buffer = new Buffer(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.modal = null; // null | {type, ...} + this.runState = "idle"; // idle | running | error + this.lastBlinkTs = 0; + this.cursorBlinkOn = true; + this.editorRowMap = new Map(); // screen row -> {pos, line} + this.statusFlash = null; + } + + 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"); + + this.initRuntime(); + + process.stdin.on("data", (chunk) => this.onInput(chunk)); + process.stdout.on("resize", () => this.onResize()); + process.on("SIGINT", () => this.quit()); + process.on("SIGTERM", () => this.quit()); + process.on("uncaughtException", (e) => this.onFatal(e)); + process.on("unhandledRejection", (e) => this.onFatal(e)); + + this.flash("Press ^S to run · ^E for examples · ^Q to quit", 4000); + this.loop(); + } + + loop() { + const tick = () => { + const now = Date.now(); + if (this.message && now > this.messageUntil) { + this.message = null; + this.dirty = true; + } + if (this.dirty) this.render(); + }; + this.tickInterval = setInterval(tick, 80); + } + + flash(msg, ms = 2500) { + this.message = msg; + this.messageUntil = Date.now() + ms; + this.dirty = true; + } + + onFatal(e) { + 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); + } catch { + /* ignore */ + } + } + + // ------------------------------------------------------------------------- + // Runtime + initRuntime() { + this.runtime?.destroy?.(); + this.runtime = createRuntime(this.buffer.text); + this.runtime.onChanges(({changes}) => { + if (!changes || !changes.length) return; + this.buffer.applyChanges(changes); + // Keep the runtime's internal view of `code` in sync with the buffer + // — the web editor does the same in its EditorView change handler. + this.runtime.setCode(this.buffer.text); + this.dirty = true; + }); + this.runtime.onError(({error}) => { + this.runError = error; + this.runState = "error"; + this.dirty = true; + }); + this.runtime.setIsRunning(true); + try { + this.runtime.run(); + this.runState = "running"; + } catch (e) { + this.runError = e; + this.runState = "error"; + } + } + + runNow(force = false) { + if (!this.runtime) return this.initRuntime(); + this.runtime.setIsRunning(true); + try { + this.runError = null; + this.runtime.setCode(this.buffer.text); + this.runtime.run(); + this.runState = "running"; + this.flash("Running…", 800); + } catch (e) { + this.runError = e; + this.runState = "error"; + this.flash("Run failed: " + (e?.message || e), 4000); + } + this.dirty = true; + } + + stopNow() { + this.runtime?.setIsRunning(false); + this.runState = "idle"; + this.flash("Stopped", 1500); + this.dirty = true; + } + + // ------------------------------------------------------------------------- + // Layout + editorBox() { + const top = HEADER_ROWS; + const bottom = this.rows - FOOTER_ROWS; + const left = 0; + const right = this.cols; + return {top, bottom, left, right, width: right - left, height: bottom - top}; + } + + visibleEditorRows() { + const box = this.editorBox(); + return box.height; + } + + // ------------------------------------------------------------------------- + // Input + onInput(chunk) { + if (this.modal) return this.onModalInput(chunk); + const events = scr.parseInput(chunk); + for (const ev of events) this.handleEvent(ev); + } + + handleEvent(ev) { + 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) { + this.runtime?.setIsRunning(false); + this.runState = "idle"; + fn(); + this.runtime?.setCode(this.buffer.text); + this.ensureCursorVisible(); + this.dirty = true; + } + + handleKey(ev) { + 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(true); + 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 === "o") return this.openFilePrompt(); + if (ctrl && name === "w") return this.savePrompt(); + 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) { + const box = this.editorBox(); + const {row, col, kind, button, shift} = ev; + 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(true); + } + } + + scrollBy(dy) { + const max = Math.max(0, this.buffer.lineCount - this.visibleEditorRows()); + this.scrollY = Math.max(0, Math.min(max, this.scrollY + dy)); + 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; + // 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.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.grid.clear(); + this.editorRowMap.clear(); + this.drawHeader(); + this.drawEditor(); + 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 < this.cols + ) { + 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 stateColor = this.runState === "error" ? COLORS.error : this.runState === "running" ? COLORS.output : COLORS.dimText; + const stateDot = fg(stateColor) + "●" + reset; + const stateText = + this.runState === "error" + ? "error" + : this.runState === "running" + ? "running" + : "idle"; + + 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); + + const rightInfo = `${stateDot} ${dim}${stateText}${reset} ${fg(COLORS.fg)}[ Run ^S ]${reset}`; + 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(); + + 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; + + // 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 text = this.buffer.lineText(line); + const visible = text.slice(this.scrollX, this.scrollX + (box.width - GUTTER)); + const styled = highlightLine(visible); + + this.grid.writeStyled(screenRow, GUTTER, styled, ""); + + // 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 < this.cols; 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(this.cols, x1) - x0; + for (let c = 0; c < cols; c++) { + const cell = this.grid.cells[screenRow * this.cols + x0 + c]; + if (cell) cell.style = overlayStyle; + } + } + } + } + } + } + + 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 allHints = [ + [`^S`, `Run`], + [`^E`, `Examples`], + [`^O`, `Open`], + [`^W`, `Save`], + [`^X`, `Stop`], + [`^R`, `Reset`], + [`^K`, `Help`], + [`^Q`, `Quit`], + ]; + const renderHints = (items) => + items.map(([k, l]) => `${fg(COLORS.hot)}${k}${reset} ${l}`).join(" · "); + + let right; + if (this.message) { + right = " " + fg(COLORS.marker) + this.message + reset + " "; + } else if (this.runError) { + const m = this.runError?.message || String(this.runError); + const truncated = m.length > 60 ? m.slice(0, 57) + "…" : m; + right = " " + fg(COLORS.error) + "✗ " + truncated + reset + " "; + } else { + let 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.modal.type === "examples") return this.drawExamplesModal(); + if (this.modal.type === "input") return this.drawInputModal(); + if (this.modal.type === "help") return this.drawHelpModal(); + if (this.modal.type === "confirm") return this.drawConfirmModal(); + } + + onModalInput(chunk) { + const events = scr.parseInput(chunk); + for (const ev of events) { + if (this.modal.type === "examples") this.handleExamplesKey(ev); + else if (this.modal.type === "input") this.handleInputKey(ev); + else if (this.modal.type === "help") this.handleHelpKey(ev); + else if (this.modal.type === "confirm") this.handleConfirmKey(ev); + } + } + + // ---- examples picker + openExamples() { + if (!this.examplesDir) { + this.flash("No examples directory available", 2000); + return; + } + let entries = []; + try { + entries = fs + .readdirSync(this.examplesDir) + .filter((f) => f.endsWith(".recho.js")) + .sort(); + } catch (e) { + this.flash("Cannot read examples: " + e.message, 3000); + return; + } + this.modal = {type: "examples", entries, index: 0, query: "", scroll: 0}; + this.dirty = true; + } + + filteredExamples() { + const {entries, query} = this.modal; + if (!query) return entries; + const q = query.toLowerCase(); + return entries.filter((e) => e.toLowerCase().includes(q)); + } + + handleExamplesKey(ev) { + 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.modal.index = Math.min(this.filteredExamples().length - 1, this.modal.index + 1); + this.dirty = true; + return; + } + if (name === "up") { + this.modal.index = Math.max(0, this.modal.index - 1); + this.dirty = true; + return; + } + if (name === "backspace") { + this.modal.query = this.modal.query.slice(0, -1); + this.modal.index = 0; + this.dirty = true; + return; + } + if (name === "pagedown") { + this.modal.index = Math.min(this.filteredExamples().length - 1, this.modal.index + 10); + this.dirty = true; + return; + } + if (name === "pageup") { + this.modal.index = Math.max(0, this.modal.index - 10); + this.dirty = true; + return; + } + } else if (ev.type === "text") { + this.modal.query += ev.text; + this.modal.index = 0; + this.dirty = true; + } else if (ev.type === "mouse") { + if (ev.kind === "wheel-up") { + this.modal.index = Math.max(0, this.modal.index - 3); + this.dirty = true; + } else if (ev.kind === "wheel-down") { + this.modal.index = Math.min(this.filteredExamples().length - 1, this.modal.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.modal.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.modal.index = i; + this.confirmExample(); + } + } + } + } + + confirmExample() { + const list = this.filteredExamples(); + const name = list[this.modal.index]; + if (!name) return; + const file = path.join(this.examplesDir, name); + try { + const code = fs.readFileSync(file, "utf8"); + this.path = file; + this.buffer = new Buffer(code); + this.scrollY = 0; + this.scrollX = 0; + this.modal = null; + this.initRuntime(); + this.flash("Loaded " + name, 2000); + } catch (e) { + this.flash("Failed to load: " + e.message, 3000); + } + this.dirty = true; + } + + modalBox() { + 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.modal.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.modal.scroll || 0; + if (this.modal.index < scroll) scroll = this.modal.index; + if (this.modal.index >= scroll + listH) scroll = this.modal.index - listH + 1; + this.modal.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.modal.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, initial, onSubmit) { + this.modal = {type: "input", title, value: initial, onSubmit}; + this.dirty = true; + } + + handleInputKey(ev) { + if (ev.type === "key") { + if (ev.name === "escape") { + this.modal = null; + this.dirty = true; + return; + } + if (ev.name === "enter") { + const v = this.modal.value; + const cb = this.modal.onSubmit; + this.modal = null; + this.dirty = true; + cb(v); + return; + } + if (ev.name === "backspace") { + this.modal.value = this.modal.value.slice(0, -1); + this.dirty = true; + return; + } + } else if (ev.type === "text") { + this.modal.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.modal.title + " ", COLORS); + this.grid.writeStyled(top + 2, left + 2, fg(COLORS.dimText) + "value: " + reset, ""); + this.grid.writeStyled(top + 3, left + 2, this.modal.value + fg(COLORS.hot) + "▏" + reset, ""); + this.grid.writeStyled( + top + h - 1, + left + 2, + fg(COLORS.dimText) + "Enter to confirm · Esc to cancel" + reset, + "", + ); + } + + 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 Buffer(code); + this.scrollY = 0; + this.scrollX = 0; + this.initRuntime(); + this.flash("Opened " + p, 2000); + } catch (e) { + this.flash("Open failed: " + e.message, 3000); + } + this.dirty = true; + }); + } + + savePrompt() { + this.inputPrompt("Save to", this.path || "untitled.recho.js", (p) => { + if (!p) return; + try { + fs.writeFileSync(p, this.buffer.text, "utf8"); + this.path = path.resolve(p); + this.flash("Saved " + p, 2000); + } catch (e) { + this.flash("Save failed: " + e.message, 3000); + } + this.dirty = true; + }); + } + + // ---- help + openHelp() { + this.modal = {type: "help"}; + this.dirty = true; + } + handleHelpKey(ev) { + if (ev.type === "key" && (ev.name === "escape" || (ev.ctrl && ev.name === "k"))) { + this.modal = null; + this.dirty = true; + } else if (ev.type === "mouse" && ev.kind === "press") { + this.modal = null; + this.dirty = true; + } + } + drawHelpModal() { + const w = Math.min(72, this.cols - 8); + const h = Math.min(this.rows - 4, 22); + 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, " Recho · Quick help ", COLORS); + const lines = [ + "", + ` ${fg(COLORS.fn)}A reactive notebook in your terminal.${reset}`, + "", + ` Write JavaScript and call ${fg(COLORS.keyword)}echo${reset}(value).`, + ` Outputs render as ${fg(COLORS.output)}//➜ comments${reset} above each cell.`, + "", + ` ${bold}Editing${reset}`, + ` arrows / home / end / pageup / pagedown move`, + ` shift + movement select`, + ` alt + ←/→ word jumps`, + ` mouse click / drag / wheel navigate`, + "", + ` ${bold}Notebook${reset}`, + ` ^S Run ^X Stop ^R Restart runtime`, + ` ^E Examples ^O Open file ^W Save`, + ` ^K This help ^Q Quit`, + "", + ` ${fg(COLORS.dimText)}Click anywhere to dismiss.${reset}`, + ]; + for (let i = 0; i < lines.length && i < h - 2; i++) { + this.grid.writeStyled(top + 1 + i, left + 2, lines[i], ""); + } + } + + // ---- confirm + drawConfirmModal() {} + handleConfirmKey() {} +} + +function cloneGrid(g) { + 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 drawBox(grid, box, title, COLORS) { + const {top, left, width, height} = box; + const right = left + width - 1; + const bottom = top + height - 1; + const border = fg(COLORS.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(COLORS.title) + bold + t + reset, ""); + } +} + +function formatExampleName(name) { + return name.replace(/\.recho\.js$/, "").replace(/-/g, " "); +} diff --git a/terminal/buffer.js b/terminal/buffer.js new file mode 100644 index 0000000..fc3f08e --- /dev/null +++ b/terminal/buffer.js @@ -0,0 +1,223 @@ +// 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 class Buffer { + 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) { + 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) { + const {start, end} = this.lineRange(line); + return this.text.slice(start, end); + } + + posToRowCol(pos) { + 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, col) { + 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) { + // 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) { + 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() { + 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, 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; + 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() { + if (this.anchor === null) return null; + return {from: Math.min(this.anchor, this.cursor), to: Math.max(this.anchor, this.cursor)}; + } +} + +function computeLineStarts(text) { + 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, from, to, insLen, delta) { + // 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.js b/terminal/cli.js new file mode 100755 index 0000000..0e0b04a --- /dev/null +++ b/terminal/cli.js @@ -0,0 +1,84 @@ +#!/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.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const examplesDir = path.join(repoRoot, "app", "examples"); + +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, …) +`; + +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.message); + 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}); + 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 ^O open file ^W save", + " ^K help ^Q quit", + "", + ].join("\n"), + ); +} + +main(); diff --git a/terminal/highlight.js b/terminal/highlight.js new file mode 100644 index 0000000..33bd27e --- /dev/null +++ b/terminal/highlight.js @@ -0,0 +1,140 @@ +// 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, dim, italic, reset, bold} from "./screen.js"; +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; + +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", +]); + +const TOKEN_RE = new RegExp( + [ + String.raw`\/\/.*$`, // line comment (until end) + String.raw`\/\*[\s\S]*?\*\/`, // block comment + String.raw`"(?:\\.|[^"\\])*"`, // double-quoted string + String.raw`'(?:\\.|[^'\\])*'`, // single-quoted string + String.raw`\`(?:\\.|[^\\\`])*\``, // template literal (no expressions support) + String.raw`\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b`, // number + String.raw`\b[A-Za-z_$][\w$]*\b`, // identifier + String.raw`[{}()[\];,.<>:?!+\-*/%=&|^~]`, + ].join("|"), + "g", +); + +// 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) { + // Comment? Whole-line color. + const trimmed = line.trimStart(); + if (trimmed.startsWith(OUTPUT_PREFIX) || trimmed.startsWith("//➜")) { + return fg(C.output) + italic + line + reset; + } + if (trimmed.startsWith(ERROR_PREFIX) || trimmed.startsWith("//✗")) { + return fg(C.error) + italic + line + reset; + } + if (trimmed.startsWith("//")) { + return fg(C.comment) + italic + line + reset; + } + + let out = fg(C.fg); + let last = 0; + TOKEN_RE.lastIndex = 0; + let m; + while ((m = TOKEN_RE.exec(line)) !== null) { + if (m.index > last) { + out += line.slice(last, m.index); + } + const tok = m[0]; + let style; + if (tok.startsWith("//")) style = fg(C.comment) + italic; + else if (tok.startsWith("/*")) style = fg(C.comment) + italic; + else 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)) { + // Function-call heuristic: identifier followed by '('. + const next = line[m.index + tok.length]; + if (next === "(") style = fg(C.fn); + else style = fg(C.fg); + } else style = fg(C.punct); + + out += style + tok + reset + fg(C.fg); + last = m.index + tok.length; + } + if (last < line.length) out += line.slice(last); + out += reset; + return out; +} diff --git a/terminal/screen.js b/terminal/screen.js new file mode 100644 index 0000000..0f7c7b8 --- /dev/null +++ b/terminal/screen.js @@ -0,0 +1,373 @@ +// 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 function moveTo(row, col) { + return CSI + (row + 1) + ";" + (col + 1) + "H"; +} + +export function fg(n) { + return CSI + "38;5;" + n + "m"; +} +export function bg(n) { + return CSI + "48;5;" + n + "m"; +} +export function rgbFg(r, g, b) { + return CSI + "38;2;" + r + ";" + g + ";" + b + "m"; +} +export function rgbBg(r, g, b) { + 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) { + return s.replace(ANSI_RE, ""); +} +export function visibleLength(s) { + return stripAnsi(s).length; +} + +// Pad a styled string to width n using spaces (preserving ANSI). +export function padToWidth(s, n) { + 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, n) { + 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} + +const NAMED = new Map([ + [13, "enter"], + [10, "enter"], + [9, "tab"], + [127, "backspace"], + [8, "backspace"], + [27, "escape"], + [32, "space"], +]); + +export function parseInput(buf) { + // 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 = []; + 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 + let 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, col, row] = 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 = "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 = 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) { + // seq is everything after the leading ESC, e.g. "[A" or "[1;5C" + // Common sequences + const map = { + "[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], 10) - 1; + const shift = (code & 1) !== 0; + const alt = (code & 2) !== 0; + const ctrl = (code & 4) !== 0; + const tail = m[1] + m[3]; + const sub = "[" + (m[3] === "~" ? m[1] + "~" : m[3]); + const baseMap = { + "[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 { + constructor(rows, cols) { + 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, cols) { + 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, col, ch, style) { + 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, col, s, baseStyle = "") { + 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, col0, row1, col1, ch, style) { + 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, next, write) { + 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); +} From 4cc0411633b22b35d842d54b8926495523f33517 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:12:35 +0800 Subject: [PATCH 2/8] Capture runtime console output so browser-only examples don't tear the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Examples like abacus that pull in d3 fail in Node ("document is not defined"). The Observable runtime's variable-rejection path called console.error directly, which scribbled raw stack traces into the alt-screen and corrupted the layout. Process-level error handlers were also over-eager and would tear down the whole TUI on async failures from notebook code. Now: - console.{log,info,warn,error} and process.stderr.write are captured while the alt-screen is up and routed into a bounded message log. - Consecutive identical errors are coalesced with a ×N badge so a generator firing 100 rejections doesn't spam the log. - uncaughtException / unhandledRejection are logged to that same buffer instead of killing the UI. - New ^L "Console" modal shows the full captured log with timestamps, error/warn tags, scroll, and Backspace-to-clear; the status bar shows an unread-error badge and the latest error summary. - On clean exit we restore the originals and replay the most recent errors to stderr so the user isn't left wondering what failed. Also: only LF-Enter (10) needs the special name; ^L (12) is now a free binding for the console keymap. Co-Authored-By: Claude Opus 4.7 --- terminal/app.js | 293 ++++++++++++++++++++++++++++++++++++++++++--- terminal/screen.js | 3 + 2 files changed, 280 insertions(+), 16 deletions(-) diff --git a/terminal/app.js b/terminal/app.js index 51db413..ddf4673 100644 --- a/terminal/app.js +++ b/terminal/app.js @@ -44,6 +44,11 @@ export class App { this.cursorBlinkOn = true; 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} + this.consoleSeen = 0; // index up to which the user has dismissed + this.consoleSavedHandlers = null; } start() { @@ -52,19 +57,110 @@ export class App { 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(chunk)); process.stdout.on("resize", () => this.onResize()); - process.on("SIGINT", () => this.quit()); - process.on("SIGTERM", () => this.quit()); - process.on("uncaughtException", (e) => this.onFatal(e)); - process.on("unhandledRejection", (e) => this.onFatal(e)); 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, args) => { + 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) => { + const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + if (text.trim()) this.pushConsole("error", text.replace(/\n+$/, "")); + return true; + }; + } + + 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, text) { + // 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() { + let n = 0; + for (let i = this.consoleSeen; i < this.console.length; i++) { + if (this.console[i].level === "error") n++; + } + return n; + } + + onAsyncError(error, source) { + const msg = error?.stack || error?.message || String(error); + this.pushConsole("error", `[${source}] ${msg}`); + } + loop() { const tick = () => { const now = Date.now(); @@ -100,6 +196,16 @@ export class App { 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 */ } @@ -118,9 +224,11 @@ export class App { this.runtime.setCode(this.buffer.text); this.dirty = true; }); - this.runtime.onError(({error}) => { - this.runError = error; + this.runtime.onError(({error, source}) => { + // Parse / split errors are reported here. They end up as `//✗` lines + // in the buffer too, but we want a status-bar nudge as well. this.runState = "error"; + this.pushConsole("error", `[${source || "runtime"}] ${error?.message || String(error)}`); this.dirty = true; }); this.runtime.setIsRunning(true); @@ -128,8 +236,8 @@ export class App { this.runtime.run(); this.runState = "running"; } catch (e) { - this.runError = e; this.runState = "error"; + this.pushConsole("error", "[run] " + (e?.message || String(e))); } } @@ -137,15 +245,13 @@ export class App { if (!this.runtime) return this.initRuntime(); this.runtime.setIsRunning(true); try { - this.runError = null; this.runtime.setCode(this.buffer.text); this.runtime.run(); this.runState = "running"; this.flash("Running…", 800); } catch (e) { - this.runError = e; this.runState = "error"; - this.flash("Run failed: " + (e?.message || e), 4000); + this.pushConsole("error", "[run] " + (e?.message || String(e))); } this.dirty = true; } @@ -212,6 +318,7 @@ export class App { if (ctrl && name === "e") return this.openExamples(); if (ctrl && name === "o") return this.openFilePrompt(); if (ctrl && name === "w") return this.savePrompt(); + if (ctrl && name === "l") return this.openConsole(); if (ctrl && name === "k") return this.openHelp(); if (ctrl && name === "a") { this.buffer.moveTo(0); @@ -495,9 +602,12 @@ export class App { // 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 = [ [`^S`, `Run`], [`^E`, `Examples`], + [`^L`, journalLabel], [`^O`, `Open`], [`^W`, `Save`], [`^X`, `Stop`], @@ -506,15 +616,16 @@ export class App { [`^Q`, `Quit`], ]; const renderHints = (items) => - items.map(([k, l]) => `${fg(COLORS.hot)}${k}${reset} ${l}`).join(" · "); + 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 if (this.runError) { - const m = this.runError?.message || String(this.runError); - const truncated = m.length > 60 ? m.slice(0, 57) + "…" : m; - right = " " + fg(COLORS.error) + "✗ " + truncated + reset + " "; } else { let items = allHints.slice(); let candidate = " " + renderHints(items) + " "; @@ -537,6 +648,7 @@ export class App { if (this.modal.type === "examples") return this.drawExamplesModal(); if (this.modal.type === "input") return this.drawInputModal(); if (this.modal.type === "help") return this.drawHelpModal(); + if (this.modal.type === "console") return this.drawConsoleModal(); if (this.modal.type === "confirm") return this.drawConfirmModal(); } @@ -546,6 +658,7 @@ export class App { if (this.modal.type === "examples") this.handleExamplesKey(ev); else if (this.modal.type === "input") this.handleInputKey(ev); else if (this.modal.type === "help") this.handleHelpKey(ev); + else if (this.modal.type === "console") this.handleConsoleKey(ev); else if (this.modal.type === "confirm") this.handleConfirmKey(ev); } } @@ -826,7 +939,7 @@ export class App { ` ${bold}Notebook${reset}`, ` ^S Run ^X Stop ^R Restart runtime`, ` ^E Examples ^O Open file ^W Save`, - ` ^K This help ^Q Quit`, + ` ^L Console ^K This help ^Q Quit`, "", ` ${fg(COLORS.dimText)}Click anywhere to dismiss.${reset}`, ]; @@ -835,6 +948,154 @@ export class App { } } + // ---- 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) { + 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.modal.scroll = Math.max(0, this.modal.scroll - 1); + this.dirty = true; + return; + } + if (name === "down") { + this.modal.scroll = Math.min(this.console.length - 1, this.modal.scroll + 1); + this.dirty = true; + return; + } + if (name === "pageup") { + this.modal.scroll = Math.max(0, this.modal.scroll - 10); + this.dirty = true; + return; + } + if (name === "pagedown") { + this.modal.scroll = Math.min(this.console.length - 1, this.modal.scroll + 10); + this.dirty = true; + return; + } + if (name === "home") { + this.modal.scroll = 0; + this.dirty = true; + return; + } + if (name === "end") { + this.modal.scroll = Math.max(0, this.console.length - 1); + this.dirty = true; + return; + } + if (name === "delete" || name === "backspace") { + this.console = []; + this.consoleSeen = 0; + this.modal.scroll = 0; + this.flash("Console cleared", 1200); + this.dirty = true; + return; + } + } else if (ev.type === "mouse") { + if (ev.kind === "wheel-up") { + this.modal.scroll = Math.max(0, this.modal.scroll - 3); + this.dirty = true; + } else if (ev.kind === "wheel-down") { + this.modal.scroll = Math.min(this.console.length - 1, this.modal.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 = []; + 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.modal.scroll` entry. + for (let i = 0; i < lines.length; i++) { + if (lines[i].entryIndex >= this.modal.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() {} diff --git a/terminal/screen.js b/terminal/screen.js index 0f7c7b8..872049a 100644 --- a/terminal/screen.js +++ b/terminal/screen.js @@ -92,6 +92,9 @@ export function truncateToWidth(s, n) { // 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"], From f50bf826904397fddafbd842d9bb175fcd37b496 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:33:17 +0800 Subject: [PATCH 3/8] Run cell expressions through vm.Script with a timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An infinite loop in a cell used to freeze the TUI forever — user input, rendering, and the runtime were all blocked by a synchronous `while (true)`. Now `safeEval` compiles the cell function with `vm.runInThisContext` and calls it through a tiny pre-compiled wrapper script with `timeout` armed. A synchronous infinite loop is interrupted after 1000ms (configurable via `createRuntime(code, { cellTimeoutMs })`), surfaced as a friendly `TimeoutError`, captured into the message log, and surfaced inline as //✗ in the buffer. The host realm is reused intentionally so primordials (Object, Array, Promise) match the rest of the runtime — sharing a fresh vm context produced `[Object: null prototype]` in inspector output and broke existing snapshots. We trade isolation for liveness; the user is running their own notebook, so the goal is keeping the editor alive, not security. Async hangs (timers, awaits) are *not* caught — those need a worker thread; this commit covers the most common shape of the bug. Status indicator upgrades: - title bar shows a Braille spinner while running, ● success / ● error / ⏱ timed out / ○ idle once settled, plus run elapsed time; - run state settles after a 500ms-quiet window (no new changes, no new errors); - timeouts are detected by sniffing the captured error and shown distinctly from generic errors. All 114 existing tests still pass (TZ=America/New_York). PTY test confirms an infinite-loop cell now times out, the TUI recovers, and a follow-up cell runs successfully. Co-Authored-By: Claude Opus 4.7 --- runtime/index.js | 109 +++++++++++++++++++++++++++++++++++++++---- terminal/app.js | 118 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 202 insertions(+), 25 deletions(-) diff --git a/runtime/index.js b/runtime/index.js index 1e24999..a4cc972 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,99 @@ 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 { + // eslint-disable-next-line no-new-func + 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 +123,8 @@ function safeEval(code, inputs, __setEcho__) { } } +export {CellTimeoutError}; + function debounce(fn, delay = 0) { let timeout; return (...args) => { @@ -43,10 +133,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 +456,9 @@ 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/terminal/app.js b/terminal/app.js index ddf4673..9509fba 100644 --- a/terminal/app.js +++ b/terminal/app.js @@ -39,16 +39,28 @@ export class App { this.cursorVisible = true; this.mouseSelecting = false; this.modal = null; // null | {type, ...} - this.runState = "idle"; // idle | running | error - this.lastBlinkTs = 0; - this.cursorBlinkOn = true; + // 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} + 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; } start() { @@ -168,11 +180,53 @@ export class App { 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) { + for (let i = this.console.length - 1; i >= fromIndex; i--) { + const m = this.console[i]; + if (m.level === "error") return m; + } + return null; + } + + lastErrorIsTimeout() { + 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, ms = 2500) { this.message = msg; this.messageUntil = Date.now() + ms; @@ -215,13 +269,14 @@ export class App { // Runtime initRuntime() { this.runtime?.destroy?.(); - this.runtime = createRuntime(this.buffer.text); + this.runtime = createRuntime(this.buffer.text, {cellTimeoutMs: this.cellTimeoutMs}); this.runtime.onChanges(({changes}) => { if (!changes || !changes.length) return; this.buffer.applyChanges(changes); // Keep the runtime's internal view of `code` in sync with the buffer // — the web editor does the same in its EditorView change handler. this.runtime.setCode(this.buffer.text); + this.lastChangeTs = Date.now(); this.dirty = true; }); this.runtime.onError(({error, source}) => { @@ -231,23 +286,31 @@ export class App { this.pushConsole("error", `[${source || "runtime"}] ${error?.message || String(error)}`); this.dirty = true; }); + this.markRunStart(); this.runtime.setIsRunning(true); try { this.runtime.run(); - this.runState = "running"; } catch (e) { this.runState = "error"; this.pushConsole("error", "[run] " + (e?.message || String(e))); } } + markRunStart() { + this.runState = "running"; + this.runStartTs = Date.now(); + this.runErrorCountAtStart = this.console.length; + this.spinnerFrame = 0; + this.dirty = true; + } + runNow(force = false) { if (!this.runtime) return this.initRuntime(); + this.markRunStart(); this.runtime.setIsRunning(true); try { this.runtime.setCode(this.buffer.text); this.runtime.run(); - this.runState = "running"; this.flash("Running…", 800); } catch (e) { this.runState = "error"; @@ -498,20 +561,41 @@ export class App { drawHeader() { const title = " Recho · " + (this.path ? path.basename(this.path) : "untitled.recho.js"); - const stateColor = this.runState === "error" ? COLORS.error : this.runState === "running" ? COLORS.output : COLORS.dimText; - const stateDot = fg(stateColor) + "●" + reset; - const stateText = - this.runState === "error" - ? "error" - : this.runState === "running" - ? "running" - : "idle"; - 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); - const rightInfo = `${stateDot} ${dim}${stateText}${reset} ${fg(COLORS.fg)}[ Run ^S ]${reset}`; + // 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); From c3e730d83d4f8ad2e0c1f120ac78b9f0ef81c055 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:42:35 +0800 Subject: [PATCH 4/8] Run terminal runtime in a worker thread --- runtime/index.js | 16 ++-- runtime/worker.js | 161 ++++++++++++++++++++++++++++++++++ terminal/app.js | 60 +++++++++++-- terminal/workerRuntime.js | 174 +++++++++++++++++++++++++++++++++++++ test/workerRuntime.spec.js | 58 +++++++++++++ 5 files changed, 449 insertions(+), 20 deletions(-) create mode 100644 runtime/worker.js create mode 100644 terminal/workerRuntime.js create mode 100644 test/workerRuntime.spec.js diff --git a/runtime/index.js b/runtime/index.js index a4cc972..eb72c9b 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -37,17 +37,14 @@ const DEFAULT_CELL_TIMEOUT_MS = 1000; 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"}, - ) + ? 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 || "")) + error && (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || /Script execution timed out/i.test(error?.message || "")) ); } @@ -82,7 +79,6 @@ function safeEval(code, inputs, __setEcho__, timeoutMs) { if (HAS_VM) { fn = vm.runInThisContext(body, {filename: "recho:cell.js"}); } else { - // eslint-disable-next-line no-new-func fn = new Function("__return__", `return ${body};`)(null); } @@ -456,9 +452,7 @@ export function createRuntime(initialCode, options = {}) { ); v._shadow.set("echo", vd); const newInputs = [...inputs, "echo"]; - state.variables.push( - v.define(vid, newInputs, safeEval(body, newInputs, __setEcho__, cellTimeoutMs)), - ); + 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/worker.js b/runtime/worker.js new file mode 100644 index 0000000..cbdf784 --- /dev/null +++ b/runtime/worker.js @@ -0,0 +1,161 @@ +// 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.js must be loaded as a worker_thread."); +} + +// -- Capture console & stderr BEFORE loading the runtime, so anything the +// stdlib (or its observer.rejected path) writes during boot is forwarded. +function safePost(msg) { + try { + parentPort.postMessage(msg); + } catch { + /* parent gone */ + } +} + +function stringifyArgs(args) { + 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) { + if (!e) return {message: "(unknown error)"}; + return { + message: e.message || String(e), + name: e.name || "Error", + stack: e.stack || null, + code: e.code || null, + }; +} + +for (const level of ["log", "info", "warn", "error", "debug"]) { + console[level] = (...args) => safePost({type: "console", level, text: stringifyArgs(args)}); +} + +const origStderrWrite = process.stderr.write.bind(process.stderr); +process.stderr.write = (chunk, encoding, callback) => { + 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; +}; + +// 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 = null; + +function bootRuntime(code, options) { + 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 || {}); + 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"}); + }); +} + +parentPort.on("message", (msg) => { + 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?.type)}\n`); + } catch { + /* ignore */ + } + } +}); + +safePost({type: "online"}); diff --git a/terminal/app.js b/terminal/app.js index 9509fba..a0c69b3 100644 --- a/terminal/app.js +++ b/terminal/app.js @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; -import {createRuntime} from "../runtime/index.js"; +import {createWorkerRuntime} from "./workerRuntime.js"; import {OUTPUT_PREFIX, ERROR_PREFIX} from "../runtime/output.js"; import {Buffer} from "./buffer.js"; import {highlightLine, COLORS} from "./highlight.js"; @@ -266,26 +266,55 @@ export class App { } // ------------------------------------------------------------------------- - // Runtime + // 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?.(); - this.runtime = createRuntime(this.buffer.text, {cellTimeoutMs: this.cellTimeoutMs}); + this.runtime = createWorkerRuntime(this.buffer.text, { + cellTimeoutMs: this.cellTimeoutMs, + heartbeatGraceMs: 3000, + }); this.runtime.onChanges(({changes}) => { if (!changes || !changes.length) return; this.buffer.applyChanges(changes); - // Keep the runtime's internal view of `code` in sync with the buffer - // — the web editor does the same in its EditorView change handler. this.runtime.setCode(this.buffer.text); this.lastChangeTs = Date.now(); this.dirty = true; }); this.runtime.onError(({error, source}) => { - // Parse / split errors are reported here. They end up as `//✗` lines - // in the buffer too, but we want a status-bar nudge as well. + // 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"}] ${error?.message || String(error)}`); this.dirty = true; }); + this.runtime.onConsole(({level, text}) => { + // Forwarded console output from the worker thread (notebook code, + // d3-require failures, observer.rejected, …). + this.pushConsole(level, text); + }); + this.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 { + this.runtime.restart(this.buffer.text); + } catch (e) { + this.pushConsole("error", "[watchdog] restart failed: " + (e?.message || String(e))); + } + }); + this.runtime.onExit?.(({code}) => { + if (code !== 0 && code != null) { + this.pushConsole("error", `[worker] exited with code ${code}`); + } + }); this.markRunStart(); this.runtime.setIsRunning(true); try { @@ -310,6 +339,11 @@ export class App { 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) { @@ -320,9 +354,17 @@ export class App { } stopNow() { - this.runtime?.setIsRunning(false); + // 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.flash("Stopped", 1500); this.dirty = true; } diff --git a/terminal/workerRuntime.js b/terminal/workerRuntime.js new file mode 100644 index 0000000..4efd59d --- /dev/null +++ b/terminal/workerRuntime.js @@ -0,0 +1,174 @@ +// 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"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WORKER_PATH = path.resolve(__dirname, "..", "runtime", "worker.js"); + +function workerExecArgv() { + return process.execArgv.filter((arg) => !arg.startsWith("--input-type")); +} + +export function createWorkerRuntime(initialCode, options = {}) { + 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 = null; + let alive = false; + let hung = false; + let isRunning = false; + let booting = false; + let lastHeartbeat = Date.now(); + let lastCode = initialCode; + let watchdog = null; + + function spawn(code) { + 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) { + 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 = 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) { + lastCode = code; + worker?.postMessage({type: "setCode", code}); + }, + setIsRunning(value) { + 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) { + spawn(code ?? lastCode); + isRunning = true; + }, + destroy() { + if (watchdog) clearInterval(watchdog); + teardownWorker(); + isRunning = false; + }, + onChanges: (cb) => dispatcher.on("changes", cb), + onError: (cb) => dispatcher.on("error", cb), + onConsole: (cb) => dispatcher.on("console", cb), + onReady: (cb) => dispatcher.on("ready", cb), + onOnline: (cb) => dispatcher.on("online", cb), + onHung: (cb) => dispatcher.on("hung", cb), + onExit: (cb) => dispatcher.on("exit", cb), + isBooting: () => booting, + }; +} diff --git a/test/workerRuntime.spec.js b/test/workerRuntime.spec.js new file mode 100644 index 0000000..b1bdb50 --- /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.js"; + +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(); + } + }); +}); From b5f816c2dfb800297374ea3e05c946237d71d708 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:21:48 +0800 Subject: [PATCH 5/8] Use D3 subpackages in examples --- app/docs/aynchronous-operations.recho.js | 2 +- app/docs/getting-started.recho.js | 4 +- app/examples/abacus.recho.js | 2 +- app/examples/animals-isotype-chart.recho.js | 9 +- app/examples/fire!.recho.js | 2 +- app/examples/matrix-rain.recho.js | 2 +- app/examples/ml5-handpose.recho.js | 2 +- app/examples/moon-sundial.recho.js | 2 +- app/examples/phases-of-the-moon.recho.js | 2 +- app/examples/random-histogram.recho.js | 2 +- app/examples/sorting.recho.js | 2 +- package.json | 7 ++ pnpm-lock.yaml | 93 ++++++++++++++++++++- runtime/stdlib/index.js | 39 ++++++++- test/js/matrix-rain.js | 2 +- test/js/random-histogram.js | 2 +- test/stdlib.spec.js | 17 +++- 17 files changed, 168 insertions(+), 23 deletions(-) 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/package.json b/package.json index cd45378..6bab738 100644 --- a/package.json +++ b/package.json @@ -91,19 +91,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/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/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"]); +}); From 0cd22260469f268f4ef7c21cda4b7602e0f3bc27 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:24:17 +0800 Subject: [PATCH 6/8] Add draggable terminal scrollbar --- terminal/app.js | 110 +++++++++++++++++++++++++++++++-- test/terminalScrollbar.spec.js | 43 +++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 test/terminalScrollbar.spec.js diff --git a/terminal/app.js b/terminal/app.js index a0c69b3..a954789 100644 --- a/terminal/app.js +++ b/terminal/app.js @@ -38,6 +38,8 @@ export class App { 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) @@ -374,7 +376,7 @@ export class App { const top = HEADER_ROWS; const bottom = this.rows - FOOTER_ROWS; const left = 0; - const right = this.cols; + const right = Math.max(left, this.cols - 1); return {top, bottom, left, right, width: right - left, height: bottom - top}; } @@ -504,6 +506,7 @@ export class App { handleMouse(ev) { 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; @@ -540,9 +543,86 @@ export class App { } } + handleScrollbarMouse(ev, box = this.editorBox()) { + 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) { - const max = Math.max(0, this.buffer.lineCount - this.visibleEditorRows()); - this.scrollY = Math.max(0, Math.min(max, this.scrollY + dy)); + this.scrollY = this.clampScrollY(this.scrollY + dy); + this.dirty = true; + } + + maxScrollY() { + return Math.max(0, this.buffer.lineCount - this.visibleEditorRows()); + } + + clampScrollY(value) { + return Math.max(0, Math.min(this.maxScrollY(), value)); + } + + scrollbarCol() { + return this.cols > 0 ? this.cols - 1 : -1; + } + + scrollbarState(box = this.editorBox()) { + 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, 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; } @@ -551,6 +631,7 @@ export class App { 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; @@ -561,6 +642,8 @@ export class App { 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); @@ -571,10 +654,12 @@ export class App { // 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)); @@ -590,7 +675,7 @@ export class App { screenRow >= box.top && screenRow < box.bottom && screenCol >= GUTTER && - screenCol < this.cols + screenCol < box.right ) { process.stdout.write(scr.moveTo(screenRow, screenCol) + scr.showCursor); } else { @@ -681,7 +766,7 @@ export class App { // contains an inline `reset`. if (isCursorLine) { const lnBg = bg(COLORS.cursorLineBg); - for (let c = GUTTER; c < this.cols; c++) { + for (let c = GUTTER; c < box.right; c++) { const cell = this.grid.cells[screenRow * this.cols + c]; if (cell) cell.style = cell.style + lnBg; } @@ -701,7 +786,7 @@ export class App { if (x1 > x0) { const overlayStyle = bg(COLORS.selBg) + fg(255); // Keep the existing characters, just override style. - const cols = Math.min(this.cols, x1) - x0; + 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; @@ -712,6 +797,19 @@ export class App { } } + 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); diff --git a/test/terminalScrollbar.spec.js b/test/terminalScrollbar.spec.js new file mode 100644 index 0000000..0a37d37 --- /dev/null +++ b/test/terminalScrollbar.spec.js @@ -0,0 +1,43 @@ +import {describe, expect, it} from "vitest"; +import {App} from "../terminal/app.js"; + +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); + }); +}); From ae2576c4743b2a56ef9a6e9bdd41696b8dac1efc Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:47:33 +0800 Subject: [PATCH 7/8] Convert terminal UI to TypeScript --- .gitignore | 3 +- eslint.config.mjs | 10 +- package.json | 11 +- runtime/{worker.js => worker.ts} | 70 +++- terminal/{app.js => app.ts} | 389 ++++++++++-------- terminal/{buffer.js => buffer.ts} | 35 +- terminal/{cli.js => cli.ts} | 4 +- terminal/{highlight.js => highlight.ts} | 9 +- terminal/{screen.js => screen.ts} | 89 ++-- .../{workerRuntime.js => workerRuntime.ts} | 65 ++- test/terminalScrollbar.spec.js | 2 +- test/workerRuntime.spec.js | 2 +- types/d3-dispatch.d.ts | 6 + 13 files changed, 438 insertions(+), 257 deletions(-) rename runtime/{worker.js => worker.ts} (70%) rename terminal/{app.js => app.ts} (79%) rename terminal/{buffer.js => buffer.ts} (88%) rename terminal/{cli.js => cli.ts} (94%) rename terminal/{highlight.js => highlight.ts} (94%) rename terminal/{screen.js => screen.ts} (83%) rename terminal/{workerRuntime.js => workerRuntime.ts} (67%) create mode 100644 types/d3-dispatch.d.ts 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/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 6bab738..0870ebb 100644 --- a/package.json +++ b/package.json @@ -26,18 +26,19 @@ ], "type": "module", "bin": { - "recho": "./terminal/cli.js" + "recho": "./terminal/cli.ts" }, "scripts": { "dev": "vite", - "tui": "node terminal/cli.js", + "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", diff --git a/runtime/worker.js b/runtime/worker.ts similarity index 70% rename from runtime/worker.js rename to runtime/worker.ts index cbdf784..13b28a7 100644 --- a/runtime/worker.js +++ b/runtime/worker.ts @@ -24,20 +24,49 @@ import {parentPort} from "node:worker_threads"; if (!parentPort) { - throw new Error("runtime/worker.js must be loaded as a worker_thread."); + 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) { +function safePost(msg: WorkerPostMessage) { try { - parentPort.postMessage(msg); + port.postMessage(msg); } catch { /* parent gone */ } } -function stringifyArgs(args) { +function stringifyArgs(args: unknown[]): string { return args .map((a) => { if (a instanceof Error) return (a.stack ? a.stack : a.message) || String(a); @@ -51,28 +80,33 @@ function stringifyArgs(args) { .join(" "); } -function serializeError(e) { - if (!e) return {message: "(unknown error)"}; +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: e.message || String(e), - name: e.name || "Error", - stack: e.stack || null, - code: e.code || null, + 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"]) { +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, encoding, callback) => { +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). @@ -90,16 +124,16 @@ process.on("uncaughtException", (e) => { const HEARTBEAT_MS = 200; setInterval(() => safePost({type: "heartbeat", t: Date.now()}), HEARTBEAT_MS).unref(); -let rt = null; +let rt: RuntimeInstance | null = null; -function bootRuntime(code, options) { +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 || {}); + 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); @@ -112,7 +146,7 @@ function bootRuntime(code, options) { }); } -parentPort.on("message", (msg) => { +port.on("message", (msg: WorkerCommand) => { switch (msg?.type) { case "init": bootRuntime(msg.code, msg.options).catch((e) => @@ -151,7 +185,7 @@ parentPort.on("message", (msg) => { default: // Unknown message — log to original stderr (which we replaced above). try { - origStderrWrite(`recho-worker: unknown message type ${JSON.stringify(msg?.type)}\n`); + origStderrWrite(`recho-worker: unknown message type ${JSON.stringify((msg as {type?: unknown}).type)}\n`); } catch { /* ignore */ } diff --git a/terminal/app.js b/terminal/app.ts similarity index 79% rename from terminal/app.js rename to terminal/app.ts index a954789..bcf9d57 100644 --- a/terminal/app.js +++ b/terminal/app.ts @@ -4,27 +4,100 @@ import fs from "node:fs"; import path from "node:path"; -import {createWorkerRuntime} from "./workerRuntime.js"; -import {OUTPUT_PREFIX, ERROR_PREFIX} from "../runtime/output.js"; -import {Buffer} from "./buffer.js"; -import {highlightLine, COLORS} from "./highlight.js"; -import * as scr from "./screen.js"; -import {fg, bg, rgbBg, rgbFg, dim, italic, reset, bold, inverse, moveTo, padToWidth, truncateToWidth, visibleLength} from "./screen.js"; +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, COLORS} from "./highlight.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 " -const HELP_LINES = [ - "Welcome to Recho · the reactive notebook in your terminal.", - "Type code; press ^S to run it. Output appears inline as //➜ comments.", -]; +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}; +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; +}; +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 { - constructor({initialPath, initialCode, examplesDir}) { + path: string | null; + examplesDir: string | 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}: AppOptions) { this.path = initialPath; this.examplesDir = examplesDir; - this.buffer = new Buffer(initialCode); + this.buffer = new DocumentBuffer(initialCode); this.scrollY = 0; this.scrollX = 0; this.cols = process.stdout.columns || 100; @@ -65,6 +138,10 @@ export class App { 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); @@ -79,7 +156,7 @@ export class App { this.initRuntime(); - process.stdin.on("data", (chunk) => this.onInput(chunk)); + 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); @@ -106,7 +183,7 @@ export class App { info: console.info, stderrWrite: process.stderr.write.bind(process.stderr), }; - const push = (level, args) => { + const push = (level: string, args: unknown[]) => { const text = args .map((a) => { if (a instanceof Error) return a.message + (a.stack ? "\n" + a.stack : ""); @@ -126,11 +203,12 @@ export class App { 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) => { - const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + 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() { @@ -143,7 +221,7 @@ export class App { this.consoleSavedHandlers = null; } - pushConsole(level, text) { + 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. @@ -162,7 +240,7 @@ export class App { this.dirty = true; } - unseenErrorCount() { + unseenErrorCount(): number { let n = 0; for (let i = this.consoleSeen; i < this.console.length; i++) { if (this.console[i].level === "error") n++; @@ -170,8 +248,8 @@ export class App { return n; } - onAsyncError(error, source) { - const msg = error?.stack || error?.message || String(error); + onAsyncError(error: unknown, source: string) { + const msg = errorStackOrMessage(error); this.pushConsole("error", `[${source}] ${msg}`); } @@ -199,11 +277,7 @@ export class App { 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.runState = seenErrors ? (this.lastErrorIsTimeout() ? "timeout" : "error") : "success"; this.runFinishTs = now; this.dirty = true; } @@ -213,7 +287,7 @@ export class App { this.tickInterval = setInterval(tick, 80); } - lastErrorAfter(fromIndex) { + 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; @@ -221,21 +295,19 @@ export class App { return null; } - lastErrorIsTimeout() { + 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, - ); + return /TimeoutError|exceeded \d+ms|infinite loop|ERR_RECHO_CELL_TIMEOUT|Script execution timed out/i.test(m.text); } - flash(msg, ms = 2500) { + flash(msg: string, ms = 2500) { this.message = msg; this.messageUntil = Date.now() + ms; this.dirty = true; } - onFatal(e) { + onFatal(e: unknown) { this.cleanup(); console.error("Fatal:", e); process.exit(1); @@ -273,57 +345,55 @@ export class App { // unresponsive for `heartbeatGraceMs` (default 3s). initRuntime() { this.runtime?.destroy?.(); - this.runtime = createWorkerRuntime(this.buffer.text, { + const runtime = createWorkerRuntime(this.buffer.text, { cellTimeoutMs: this.cellTimeoutMs, heartbeatGraceMs: 3000, }); - this.runtime.onChanges(({changes}) => { + this.runtime = runtime; + runtime.onChanges(({changes}) => { if (!changes || !changes.length) return; this.buffer.applyChanges(changes); - this.runtime.setCode(this.buffer.text); + runtime.setCode(this.buffer.text); this.lastChangeTs = Date.now(); this.dirty = true; }); - this.runtime.onError(({error, source}) => { + 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"}] ${error?.message || String(error)}`); + this.pushConsole("error", `[${source || "runtime"}] ${errorMessage(error)}`); this.dirty = true; }); - this.runtime.onConsole(({level, text}) => { + runtime.onConsole(({level, text}) => { // Forwarded console output from the worker thread (notebook code, // d3-require failures, observer.rejected, …). this.pushConsole(level, text); }); - this.runtime.onHung(({sinceHeartbeatMs}) => { + 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.pushConsole("error", `[watchdog] runtime unresponsive for ${sinceHeartbeatMs}ms — restarting worker thread`); this.runState = "timeout"; this.dirty = true; try { - this.runtime.restart(this.buffer.text); + runtime.restart(this.buffer.text); } catch (e) { - this.pushConsole("error", "[watchdog] restart failed: " + (e?.message || String(e))); + this.pushConsole("error", "[watchdog] restart failed: " + errorMessage(e)); } }); - this.runtime.onExit?.(({code}) => { + runtime.onExit?.(({code}) => { if (code !== 0 && code != null) { this.pushConsole("error", `[worker] exited with code ${code}`); } }); this.markRunStart(); - this.runtime.setIsRunning(true); + runtime.setIsRunning(true); try { - this.runtime.run(); + runtime.run(); } catch (e) { this.runState = "error"; - this.pushConsole("error", "[run] " + (e?.message || String(e))); + this.pushConsole("error", "[run] " + errorMessage(e)); } } @@ -335,7 +405,7 @@ export class App { this.dirty = true; } - runNow(force = false) { + runNow() { if (!this.runtime) return this.initRuntime(); this.markRunStart(); this.runtime.setIsRunning(true); @@ -350,7 +420,7 @@ export class App { this.flash("Running…", 800); } catch (e) { this.runState = "error"; - this.pushConsole("error", "[run] " + (e?.message || String(e))); + this.pushConsole("error", "[run] " + errorMessage(e)); } this.dirty = true; } @@ -372,7 +442,7 @@ export class App { // ------------------------------------------------------------------------- // Layout - editorBox() { + editorBox(): EditorBox { const top = HEADER_ROWS; const bottom = this.rows - FOOTER_ROWS; const left = 0; @@ -380,20 +450,20 @@ export class App { return {top, bottom, left, right, width: right - left, height: bottom - top}; } - visibleEditorRows() { + visibleEditorRows(): number { const box = this.editorBox(); return box.height; } // ------------------------------------------------------------------------- // Input - onInput(chunk) { + 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) { + handleEvent(ev: scr.InputEvent) { if (ev.type === "text") { this.userEdit(() => this.buffer.insertAtCursor(ev.text)); return; @@ -402,7 +472,7 @@ export class App { if (ev.type === "mouse") return this.handleMouse(ev); } - userEdit(fn) { + userEdit(fn: () => void) { this.runtime?.setIsRunning(false); this.runState = "idle"; fn(); @@ -411,11 +481,11 @@ export class App { this.dirty = true; } - handleKey(ev) { + 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(true); + if (ctrl && name === "s") return this.runNow(); if (ctrl && name === "x") return this.stopNow(); if (ctrl && name === "r") { this.initRuntime(); @@ -483,7 +553,7 @@ export class App { // 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 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)); @@ -503,7 +573,7 @@ export class App { this.dirty = true; } - handleMouse(ev) { + handleMouse(ev: scr.MouseEvent) { const box = this.editorBox(); const {row, col, kind, button, shift} = ev; if (this.handleScrollbarMouse(ev, box)) return; @@ -539,11 +609,11 @@ export class App { // 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(true); + if (col >= hotStart) this.runNow(); } } - handleScrollbarMouse(ev, box = this.editorBox()) { + handleScrollbarMouse(ev: scr.MouseEvent, box = this.editorBox()): boolean { const scrollbarCol = this.scrollbarCol(); if (scrollbarCol < 0) return false; const {row, col, kind, button} = ev; @@ -575,30 +645,31 @@ export class App { return kind === "drag" && col === scrollbarCol; } - scrollBy(dy) { + scrollBy(dy: number) { this.scrollY = this.clampScrollY(this.scrollY + dy); this.dirty = true; } - maxScrollY() { + maxScrollY(): number { return Math.max(0, this.buffer.lineCount - this.visibleEditorRows()); } - clampScrollY(value) { + clampScrollY(value: number): number { return Math.max(0, Math.min(this.maxScrollY(), value)); } - scrollbarCol() { + scrollbarCol(): number { return this.cols > 0 ? this.cols - 1 : -1; } - scrollbarState(box = this.editorBox()) { + 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 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; @@ -614,7 +685,7 @@ export class App { }; } - scrollToScrollbarRow(thumbTopRow, box = this.editorBox()) { + scrollToScrollbarRow(thumbTopRow: number, box = this.editorBox()) { const state = this.scrollbarState(box); if (!state || state.maxScroll === 0) { this.scrollY = 0; @@ -671,12 +742,7 @@ export class App { 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 - ) { + 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); @@ -828,7 +894,7 @@ export class App { // the whole line fits the terminal width. const unread = this.unseenErrorCount(); const journalLabel = unread > 0 ? `Console (${unread})` : `Console`; - const allHints = [ + const allHints: [string, string][] = [ [`^S`, `Run`], [`^E`, `Examples`], [`^L`, journalLabel], @@ -839,7 +905,7 @@ export class App { [`^K`, `Help`], [`^Q`, `Quit`], ]; - const renderHints = (items) => + const renderHints = (items: [string, string][]) => items .map(([k, l]) => { const labelColor = l.startsWith("Console") && unread > 0 ? fg(COLORS.error) : ""; @@ -851,7 +917,7 @@ export class App { if (this.message) { right = " " + fg(COLORS.marker) + this.message + reset + " "; } else { - let items = allHints.slice(); + const items = allHints.slice(); let candidate = " " + renderHints(items) + " "; const leftLen = visibleLength(left); while (items.length > 2 && leftLen + visibleLength(candidate) > this.cols) { @@ -869,21 +935,21 @@ export class App { // ------------------------------------------------------------------------- // Modals drawModal() { - if (this.modal.type === "examples") return this.drawExamplesModal(); - if (this.modal.type === "input") return this.drawInputModal(); - if (this.modal.type === "help") return this.drawHelpModal(); - if (this.modal.type === "console") return this.drawConsoleModal(); - if (this.modal.type === "confirm") return this.drawConfirmModal(); + 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) { + onModalInput(chunk: string) { const events = scr.parseInput(chunk); for (const ev of events) { - if (this.modal.type === "examples") this.handleExamplesKey(ev); - else if (this.modal.type === "input") this.handleInputKey(ev); - else if (this.modal.type === "help") this.handleHelpKey(ev); - else if (this.modal.type === "console") this.handleConsoleKey(ev); - else if (this.modal.type === "confirm") this.handleConfirmKey(ev); + 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); } } @@ -893,28 +959,28 @@ export class App { this.flash("No examples directory available", 2000); return; } - let entries = []; + let entries: string[] = []; try { entries = fs .readdirSync(this.examplesDir) .filter((f) => f.endsWith(".recho.js")) .sort(); } catch (e) { - this.flash("Cannot read examples: " + e.message, 3000); + this.flash("Cannot read examples: " + errorMessage(e), 3000); return; } this.modal = {type: "examples", entries, index: 0, query: "", scroll: 0}; this.dirty = true; } - filteredExamples() { - const {entries, query} = this.modal; + filteredExamples(): string[] { + const {entries, query} = this.activeModal; if (!query) return entries; const q = query.toLowerCase(); - return entries.filter((e) => e.toLowerCase().includes(q)); + return entries.filter((e: string) => e.toLowerCase().includes(q)); } - handleExamplesKey(ev) { + handleExamplesKey(ev: scr.InputEvent) { if (ev.type === "key") { const {name, ctrl} = ev; if (name === "escape" || (ctrl && name === "g")) { @@ -924,41 +990,41 @@ export class App { } if (name === "enter") return this.confirmExample(); if (name === "down") { - this.modal.index = Math.min(this.filteredExamples().length - 1, this.modal.index + 1); + this.activeModal.index = Math.min(this.filteredExamples().length - 1, this.activeModal.index + 1); this.dirty = true; return; } if (name === "up") { - this.modal.index = Math.max(0, this.modal.index - 1); + this.activeModal.index = Math.max(0, this.activeModal.index - 1); this.dirty = true; return; } if (name === "backspace") { - this.modal.query = this.modal.query.slice(0, -1); - this.modal.index = 0; + this.activeModal.query = this.activeModal.query.slice(0, -1); + this.activeModal.index = 0; this.dirty = true; return; } if (name === "pagedown") { - this.modal.index = Math.min(this.filteredExamples().length - 1, this.modal.index + 10); + this.activeModal.index = Math.min(this.filteredExamples().length - 1, this.activeModal.index + 10); this.dirty = true; return; } if (name === "pageup") { - this.modal.index = Math.max(0, this.modal.index - 10); + this.activeModal.index = Math.max(0, this.activeModal.index - 10); this.dirty = true; return; } } else if (ev.type === "text") { - this.modal.query += ev.text; - this.modal.index = 0; + this.activeModal.query += ev.text; + this.activeModal.index = 0; this.dirty = true; } else if (ev.type === "mouse") { if (ev.kind === "wheel-up") { - this.modal.index = Math.max(0, this.modal.index - 3); + this.activeModal.index = Math.max(0, this.activeModal.index - 3); this.dirty = true; } else if (ev.kind === "wheel-down") { - this.modal.index = Math.min(this.filteredExamples().length - 1, this.modal.index + 3); + 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. @@ -966,7 +1032,7 @@ export class App { const listTop = box.top + 4; const listLeft = box.left + 1; const listRight = box.left + box.width - 1; - const i = ev.row - listTop + (this.modal.scroll || 0); + const i = ev.row - listTop + (this.activeModal.scroll || 0); if ( ev.row >= listTop && ev.row < box.top + box.height - 1 && @@ -975,7 +1041,7 @@ export class App { i >= 0 && i < this.filteredExamples().length ) { - this.modal.index = i; + this.activeModal.index = i; this.confirmExample(); } } @@ -983,26 +1049,27 @@ export class App { } confirmExample() { + if (!this.examplesDir) return; const list = this.filteredExamples(); - const name = list[this.modal.index]; + 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 Buffer(code); + 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: " + e.message, 3000); + this.flash("Failed to load: " + errorMessage(e), 3000); } this.dirty = true; } - modalBox() { + 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)); @@ -1017,28 +1084,29 @@ export class App { this.grid.writeStyled( queryLine, box.left + 2, - fg(COLORS.dimText) + "filter: " + reset + this.modal.query + fg(COLORS.dimText) + "▏" + reset, + 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.modal.scroll || 0; - if (this.modal.index < scroll) scroll = this.modal.index; - if (this.modal.index >= scroll + listH) scroll = this.modal.index - listH + 1; - this.modal.scroll = scroll; + 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.modal.index; + 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)); + 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; @@ -1046,12 +1114,12 @@ export class App { } // ---- input prompt - inputPrompt(title, initial, onSubmit) { + inputPrompt(title: string, initial: string, onSubmit: (value: string) => void) { this.modal = {type: "input", title, value: initial, onSubmit}; this.dirty = true; } - handleInputKey(ev) { + handleInputKey(ev: scr.InputEvent) { if (ev.type === "key") { if (ev.name === "escape") { this.modal = null; @@ -1059,20 +1127,20 @@ export class App { return; } if (ev.name === "enter") { - const v = this.modal.value; - const cb = this.modal.onSubmit; + const v = this.activeModal.value; + const cb = this.activeModal.onSubmit; this.modal = null; this.dirty = true; cb(v); return; } if (ev.name === "backspace") { - this.modal.value = this.modal.value.slice(0, -1); + this.activeModal.value = this.activeModal.value.slice(0, -1); this.dirty = true; return; } } else if (ev.type === "text") { - this.modal.value += ev.text; + this.activeModal.value += ev.text; this.dirty = true; } } @@ -1083,15 +1151,10 @@ export class App { 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.modal.title + " ", COLORS); + 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.modal.value + fg(COLORS.hot) + "▏" + reset, ""); - this.grid.writeStyled( - top + h - 1, - left + 2, - fg(COLORS.dimText) + "Enter to confirm · Esc to cancel" + 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, ""); } openFilePrompt() { @@ -1100,13 +1163,13 @@ export class App { try { const code = fs.readFileSync(p, "utf8"); this.path = path.resolve(p); - this.buffer = new Buffer(code); + this.buffer = new DocumentBuffer(code); this.scrollY = 0; this.scrollX = 0; this.initRuntime(); this.flash("Opened " + p, 2000); } catch (e) { - this.flash("Open failed: " + e.message, 3000); + this.flash("Open failed: " + errorMessage(e), 3000); } this.dirty = true; }); @@ -1120,7 +1183,7 @@ export class App { this.path = path.resolve(p); this.flash("Saved " + p, 2000); } catch (e) { - this.flash("Save failed: " + e.message, 3000); + this.flash("Save failed: " + errorMessage(e), 3000); } this.dirty = true; }); @@ -1131,7 +1194,7 @@ export class App { this.modal = {type: "help"}; this.dirty = true; } - handleHelpKey(ev) { + handleHelpKey(ev: scr.InputEvent) { if (ev.type === "key" && (ev.name === "escape" || (ev.ctrl && ev.name === "k"))) { this.modal = null; this.dirty = true; @@ -1179,7 +1242,7 @@ export class App { this.dirty = true; } - handleConsoleKey(ev) { + handleConsoleKey(ev: scr.InputEvent) { if (ev.type === "key") { const {name, ctrl} = ev; if (name === "escape" || (ctrl && name === "l") || (ctrl && name === "g")) { @@ -1188,49 +1251,49 @@ export class App { return; } if (name === "up") { - this.modal.scroll = Math.max(0, this.modal.scroll - 1); + this.activeModal.scroll = Math.max(0, this.activeModal.scroll - 1); this.dirty = true; return; } if (name === "down") { - this.modal.scroll = Math.min(this.console.length - 1, this.modal.scroll + 1); + this.activeModal.scroll = Math.min(this.console.length - 1, this.activeModal.scroll + 1); this.dirty = true; return; } if (name === "pageup") { - this.modal.scroll = Math.max(0, this.modal.scroll - 10); + this.activeModal.scroll = Math.max(0, this.activeModal.scroll - 10); this.dirty = true; return; } if (name === "pagedown") { - this.modal.scroll = Math.min(this.console.length - 1, this.modal.scroll + 10); + this.activeModal.scroll = Math.min(this.console.length - 1, this.activeModal.scroll + 10); this.dirty = true; return; } if (name === "home") { - this.modal.scroll = 0; + this.activeModal.scroll = 0; this.dirty = true; return; } if (name === "end") { - this.modal.scroll = Math.max(0, this.console.length - 1); + 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.modal.scroll = 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.modal.scroll = Math.max(0, this.modal.scroll - 3); + this.activeModal.scroll = Math.max(0, this.activeModal.scroll - 3); this.dirty = true; } else if (ev.kind === "wheel-down") { - this.modal.scroll = Math.min(this.console.length - 1, this.modal.scroll + 3); + this.activeModal.scroll = Math.min(this.console.length - 1, this.activeModal.scroll + 3); this.dirty = true; } } @@ -1253,18 +1316,13 @@ export class App { 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, - "", - ); + 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 = []; + const lines: {entryIndex: number; level: string; text: string}[] = []; for (let i = 0; i < this.console.length; i++) { const m = this.console[i]; const tag = @@ -1300,9 +1358,9 @@ export class App { // Ensure scroll keeps the requested entry visible at the bottom. const listH = h - 3; let firstLine = 0; - // Find the line index for `this.modal.scroll` entry. + // Find the line index for `this.activeModal.scroll` entry. for (let i = 0; i < lines.length; i++) { - if (lines[i].entryIndex >= this.modal.scroll) { + if (lines[i].entryIndex >= this.activeModal.scroll) { firstLine = Math.max(0, i - listH + 1); break; } @@ -1313,19 +1371,18 @@ export class App { 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; + 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() {} + handleConfirmKey(ev?: scr.InputEvent) { + void ev; + } } -function cloneGrid(g) { +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; @@ -1334,11 +1391,11 @@ function cloneGrid(g) { return c; } -function drawBox(grid, box, title, COLORS) { +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(COLORS.borderHi); + const border = fg(palette.borderHi); const inside = bg(233); // Fill background grid.fillRect(top, left, top + height, left + width, " ", inside); @@ -1359,10 +1416,10 @@ function drawBox(grid, box, title, COLORS) { if (title) { const t = " " + title + " "; const tcol = left + 2; - grid.writeStyled(top, tcol, bg(233) + fg(COLORS.title) + bold + t + reset, ""); + grid.writeStyled(top, tcol, bg(233) + fg(palette.title) + bold + t + reset, ""); } } -function formatExampleName(name) { +function formatExampleName(name: string): string { return name.replace(/\.recho\.js$/, "").replace(/-/g, " "); } diff --git a/terminal/buffer.js b/terminal/buffer.ts similarity index 88% rename from terminal/buffer.js rename to terminal/buffer.ts index fc3f08e..ea97026 100644 --- a/terminal/buffer.js +++ b/terminal/buffer.ts @@ -5,7 +5,18 @@ // 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); @@ -25,18 +36,18 @@ export class Buffer { return this.lineStarts.length; } - lineRange(line) { + 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) { + lineText(line: number): string { const {start, end} = this.lineRange(line); return this.text.slice(start, end); } - posToRowCol(pos) { + posToRowCol(pos: number): RowCol { pos = Math.max(0, Math.min(pos, this.text.length)); // Binary search in lineStarts. let lo = 0; @@ -49,7 +60,7 @@ export class Buffer { return {row: lo, col: pos - this.lineStarts[lo]}; } - rowColToPos(row, col) { + 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; @@ -58,7 +69,7 @@ export class Buffer { // Apply an array of CodeMirror-style change specs in document order. The // cursor is mapped through the changes (associativity = right). - applyChanges(changes) { + applyChanges(changes: ChangeSpec[]) { // Sort by `from` ascending, with delete-only first to keep ordering // deterministic. Runtime emits non-overlapping changes. const sorted = changes @@ -84,7 +95,7 @@ export class Buffer { this.preferredCol = this.posToRowCol(this.cursor).col; } - insertAtCursor(s) { + 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); @@ -93,7 +104,7 @@ export class Buffer { this.preferredCol = this.posToRowCol(this.cursor).col; } - deleteSelection() { + deleteSelection(): boolean { if (this.anchor === null) return false; const from = Math.min(this.anchor, this.cursor); const to = Math.max(this.anchor, this.cursor); @@ -127,7 +138,7 @@ export class Buffer { } // Cursor movement. `extend` keeps the selection anchor (creates one if none). - moveTo(pos, extend = false) { + 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; @@ -173,7 +184,7 @@ export class Buffer { 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; + const indent = line.match(/^\s*/)?.[0].length ?? 0; const target = this.cursor === start + indent ? start : start + indent; this.moveTo(target, extend); } @@ -200,13 +211,13 @@ export class Buffer { this.moveTo(p, extend); } - selection() { + 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) { +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); @@ -214,7 +225,7 @@ function computeLineStarts(text) { return starts; } -function mapPos(pos, from, to, insLen, delta) { +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; diff --git a/terminal/cli.js b/terminal/cli.ts similarity index 94% rename from terminal/cli.js rename to terminal/cli.ts index 0e0b04a..92a2421 100755 --- a/terminal/cli.js +++ b/terminal/cli.ts @@ -11,7 +11,7 @@ import fs from "node:fs"; import path from "node:path"; import {fileURLToPath} from "node:url"; -import {App} from "./app.js"; +import {App} from "./app.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, ".."); @@ -47,7 +47,7 @@ function main() { try { initialCode = fs.readFileSync(initialPath, "utf8"); } catch (e) { - console.error("Cannot read", initialPath + ":", e.message); + console.error("Cannot read", initialPath + ":", e instanceof Error ? e.message : String(e)); process.exit(1); } break; diff --git a/terminal/highlight.js b/terminal/highlight.ts similarity index 94% rename from terminal/highlight.js rename to terminal/highlight.ts index 33bd27e..157c738 100644 --- a/terminal/highlight.js +++ b/terminal/highlight.ts @@ -3,7 +3,7 @@ // 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, dim, italic, reset, bold} from "./screen.js"; +import {fg, italic, reset, bold} from "./screen.ts"; import {OUTPUT_PREFIX, ERROR_PREFIX} from "../runtime/output.js"; // Theme palette (256-color indices). @@ -33,6 +33,7 @@ const C = { }; export const COLORS = C; +export type ColorName = keyof typeof C; const KEYWORDS = new Set([ "break", @@ -96,7 +97,7 @@ const TOKEN_RE = new RegExp( // 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) { +export function highlightLine(line: string): string { // Comment? Whole-line color. const trimmed = line.trimStart(); if (trimmed.startsWith(OUTPUT_PREFIX) || trimmed.startsWith("//➜")) { @@ -112,13 +113,13 @@ export function highlightLine(line) { let out = fg(C.fg); let last = 0; TOKEN_RE.lastIndex = 0; - let m; + let m: RegExpExecArray | null; while ((m = TOKEN_RE.exec(line)) !== null) { if (m.index > last) { out += line.slice(last, m.index); } const tok = m[0]; - let style; + let style: string; if (tok.startsWith("//")) style = fg(C.comment) + italic; else if (tok.startsWith("/*")) style = fg(C.comment) + italic; else if (tok[0] === '"' || tok[0] === "'" || tok[0] === "`") style = fg(C.string); diff --git a/terminal/screen.js b/terminal/screen.ts similarity index 83% rename from terminal/screen.js rename to terminal/screen.ts index 872049a..d62beb0 100644 --- a/terminal/screen.js +++ b/terminal/screen.ts @@ -20,20 +20,47 @@ export const disableMouse = CSI + "?1006l" + CSI + "?1002l" + CSI + "?1000l"; export const reset = CSI + "0m"; -export function moveTo(row, col) { +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) { +export function fg(n: number): string { return CSI + "38;5;" + n + "m"; } -export function bg(n) { +export function bg(n: number): string { return CSI + "48;5;" + n + "m"; } -export function rgbFg(r, g, b) { +export function rgbFg(r: number, g: number, b: number): string { return CSI + "38;2;" + r + ";" + g + ";" + b + "m"; } -export function rgbBg(r, g, b) { +export function rgbBg(r: number, g: number, b: number): string { return CSI + "48;2;" + r + ";" + g + ";" + b + "m"; } export const bold = CSI + "1m"; @@ -48,15 +75,15 @@ 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) { +export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ""); } -export function visibleLength(s) { +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, n) { +export function padToWidth(s: string, n: number): string { const v = visibleLength(s); if (v >= n) return s; return s + " ".repeat(n - v); @@ -64,7 +91,7 @@ export function padToWidth(s, n) { // 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, n) { +export function truncateToWidth(s: string, n: number): string { if (visibleLength(s) <= n) return s; let out = ""; let used = 0; @@ -95,7 +122,7 @@ export function truncateToWidth(s, n) { // 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([ +const NAMED = new Map([ [13, "enter"], [10, "enter"], [9, "tab"], @@ -105,11 +132,11 @@ const NAMED = new Map([ [32, "space"], ]); -export function parseInput(buf) { +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 = []; + const events: InputEvent[] = []; let i = 0; const s = buf; while (i < s.length) { @@ -118,15 +145,18 @@ export function parseInput(buf) { // Escape sequences if (s[i + 1] === "[") { // CSI - let j = i + 2; + const j = i + 2; // SGR mouse: ESC[= s.length) break; - const params = s.slice(j + 1, k).split(";").map(Number); + const params = s + .slice(j + 1, k) + .split(";") + .map(Number); const action = s[k] === "M" ? "press" : "release"; - const [code, col, row] = params; + 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; @@ -135,7 +165,7 @@ export function parseInput(buf) { const shift = (code & 4) !== 0; const meta = (code & 8) !== 0; const ctrl = (code & 16) !== 0; - let kind = "press"; + let kind: MouseKind = "press"; if (action === "release") kind = "release"; else if (motion) kind = "drag"; if (wheel) kind = button === 0 ? "wheel-up" : "wheel-down"; @@ -166,7 +196,7 @@ export function parseInput(buf) { if (s[i + 1] === "O") { // SS3 (function keys, sometimes home/end) const code = s[i + 2]; - let name = null; + let name: string | null = null; if (code === "P") name = "f1"; else if (code === "Q") name = "f2"; else if (code === "R") name = "f3"; @@ -224,10 +254,10 @@ export function parseInput(buf) { return events; } -function csiToEvent(seq) { +function csiToEvent(seq: string): KeyEvent | null { // seq is everything after the leading ESC, e.g. "[A" or "[1;5C" // Common sequences - const map = { + const map: Record = { "[A": "up", "[B": "down", "[C": "right", @@ -250,13 +280,12 @@ function csiToEvent(seq) { // 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], 10) - 1; + const code = parseInt(m[2] ?? "0", 10) - 1; const shift = (code & 1) !== 0; const alt = (code & 2) !== 0; const ctrl = (code & 4) !== 0; - const tail = m[1] + m[3]; const sub = "[" + (m[3] === "~" ? m[1] + "~" : m[3]); - const baseMap = { + const baseMap: Record = { "[A": "up", "[B": "down", "[C": "right", @@ -280,14 +309,18 @@ function csiToEvent(seq) { // previously emitted grid and only repaint cells that changed. export class Grid { - constructor(rows, cols) { + 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, cols) { + resize(rows: number, cols: number) { this.rows = rows; this.cols = cols; this.cells = new Array(rows * cols); @@ -301,7 +334,7 @@ export class Grid { } } - setCell(row, col, ch, 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; @@ -311,7 +344,7 @@ export class Grid { // 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, col, s, baseStyle = "") { + writeStyled(row: number, col: number, s: string, baseStyle = ""): number { if (row < 0 || row >= this.rows) return col; let style = baseStyle; let i = 0; @@ -338,7 +371,7 @@ export class Grid { } // Fill a row range with the given (plain) char and style. - fillRect(row0, col0, row1, col1, ch, 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); @@ -348,7 +381,7 @@ export class Grid { } // Render `next` to stdout, computing a minimal diff from `prev`. -export function renderDiff(prev, next, write) { +export function renderDiff(prev: Grid | null, next: Grid, write: (output: string) => void) { let out = ""; let curStyle = ""; let curRow = -1; diff --git a/terminal/workerRuntime.js b/terminal/workerRuntime.ts similarity index 67% rename from terminal/workerRuntime.js rename to terminal/workerRuntime.ts index 4efd59d..9bacc2a 100644 --- a/terminal/workerRuntime.js +++ b/terminal/workerRuntime.ts @@ -13,15 +13,35 @@ 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.js"); +const WORKER_PATH = path.resolve(__dirname, "..", "runtime", "worker.ts"); -function workerExecArgv() { +function workerExecArgv(): string[] { return process.execArgv.filter((arg) => !arg.startsWith("--input-type")); } -export function createWorkerRuntime(initialCode, options = {}) { +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 @@ -32,16 +52,16 @@ export function createWorkerRuntime(initialCode, options = {}) { const heartbeatGraceMs = options.heartbeatGraceMs ?? 3000; const cellTimeoutMs = options.cellTimeoutMs ?? 1000; - let worker = null; + 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 = null; + let watchdog: NodeJS.Timeout | null = null; - function spawn(code) { + function spawn(code: string) { teardownWorker(); hung = false; alive = true; @@ -81,7 +101,7 @@ export function createWorkerRuntime(initialCode, options = {}) { booting = false; } - function onMessage(msg) { + function onMessage(msg: WorkerMessage) { if (!msg || typeof msg !== "object") return; switch (msg.type) { case "heartbeat": @@ -101,7 +121,7 @@ export function createWorkerRuntime(initialCode, options = {}) { dispatcher.call("changes", null, {changes: msg.changes, effects: []}); break; case "error": { - const err = new Error(msg.error?.message || "runtime 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; @@ -129,11 +149,11 @@ export function createWorkerRuntime(initialCode, options = {}) { spawn(initialCode); return { - setCode(code) { + setCode(code: string) { lastCode = code; worker?.postMessage({type: "setCode", code}); }, - setIsRunning(value) { + setIsRunning(value: boolean) { isRunning = !!value; worker?.postMessage({type: "setIsRunning", value: !!value}); }, @@ -153,7 +173,7 @@ export function createWorkerRuntime(initialCode, options = {}) { teardownWorker(); isRunning = false; }, - restart(code) { + restart(code?: string) { spawn(code ?? lastCode); isRunning = true; }, @@ -162,13 +182,22 @@ export function createWorkerRuntime(initialCode, options = {}) { teardownWorker(); isRunning = false; }, - onChanges: (cb) => dispatcher.on("changes", cb), - onError: (cb) => dispatcher.on("error", cb), - onConsole: (cb) => dispatcher.on("console", cb), - onReady: (cb) => dispatcher.on("ready", cb), - onOnline: (cb) => dispatcher.on("online", cb), - onHung: (cb) => dispatcher.on("hung", cb), - onExit: (cb) => dispatcher.on("exit", cb), + 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/terminalScrollbar.spec.js b/test/terminalScrollbar.spec.js index 0a37d37..f6abc29 100644 --- a/test/terminalScrollbar.spec.js +++ b/test/terminalScrollbar.spec.js @@ -1,5 +1,5 @@ import {describe, expect, it} from "vitest"; -import {App} from "../terminal/app.js"; +import {App} from "../terminal/app.ts"; function makeApp(lineCount = 30) { const initialCode = Array.from({length: lineCount}, (_, i) => `echo(${i});`).join("\n"); diff --git a/test/workerRuntime.spec.js b/test/workerRuntime.spec.js index b1bdb50..280f11e 100644 --- a/test/workerRuntime.spec.js +++ b/test/workerRuntime.spec.js @@ -1,6 +1,6 @@ import {describe, expect, it} from "vitest"; import {OUTPUT_PREFIX} from "../runtime/output.js"; -import {createWorkerRuntime} from "../terminal/workerRuntime.js"; +import {createWorkerRuntime} from "../terminal/workerRuntime.ts"; function waitForChanges(runtime, predicate = () => true) { return new Promise((resolve, reject) => { 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; + }; +} From 403c7dbbb4d42e4ec8653080d36110fa2632ab0d Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Apr 2026 05:38:35 +0800 Subject: [PATCH 8/8] Improve terminal editor actions and help --- terminal/app.ts | 370 +++++++++++++++++++++++++++---- terminal/cli.ts | 7 +- terminal/docs.ts | 109 +++++++++ terminal/highlight.ts | 152 +++++++++---- test/terminalFileActions.spec.js | 51 +++++ test/terminalHelp.spec.js | 28 +++ test/terminalHighlight.spec.js | 52 +++++ 7 files changed, 684 insertions(+), 85 deletions(-) create mode 100644 terminal/docs.ts create mode 100644 test/terminalFileActions.spec.js create mode 100644 test/terminalHelp.spec.js create mode 100644 test/terminalHighlight.spec.js diff --git a/terminal/app.ts b/terminal/app.ts index bcf9d57..efbcaf7 100644 --- a/terminal/app.ts +++ b/terminal/app.ts @@ -8,7 +8,9 @@ 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, COLORS} from "./highlight.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"; @@ -25,7 +27,12 @@ type ConsoleSavedHandlers = { info: typeof console.info; stderrWrite: typeof process.stderr.write; }; -type AppOptions = {initialPath: string | null; initialCode: string; examplesDir: string | null}; +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 = { @@ -47,6 +54,11 @@ type ModalBag = { title: string; value: string; onSubmit: (value: string) => void; + help: HelpDocs; + helpIndex: number; + helpSlug: string; + helpScroll: number; + helpOutlineScroll: number; }; type ModalState = (Partial & {type: string}) | null; @@ -61,6 +73,8 @@ function errorStackOrMessage(error: unknown): string { export class App { path: string | null; examplesDir: string | null; + docsDir: string | null; + helpDocs: HelpDocs | null; buffer: DocumentBuffer; scrollY: number; scrollX: number; @@ -94,9 +108,11 @@ export class App { cursorBlinkOn?: boolean; lastBlinkTs?: number; - constructor({initialPath, initialCode, examplesDir}: AppOptions) { + 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; @@ -493,8 +509,10 @@ export class App { 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") { @@ -801,6 +819,10 @@ export class App { 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; @@ -812,6 +834,7 @@ export class App { } 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); @@ -821,11 +844,9 @@ export class App { this.grid.writeStyled(screenRow, 0, gutter, ""); // Source line content (with horizontal scroll). - const text = this.buffer.lineText(line); - const visible = text.slice(this.scrollX, this.scrollX + (box.width - GUTTER)); - const styled = highlightLine(visible); - - this.grid.writeStyled(screenRow, GUTTER, styled, ""); + 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 @@ -898,8 +919,10 @@ export class App { [`^S`, `Run`], [`^E`, `Examples`], [`^L`, journalLabel], + [`^N`, `New`], [`^O`, `Open`], [`^W`, `Save`], + [`^T`, `Rename`], [`^X`, `Stop`], [`^R`, `Reset`], [`^K`, `Help`], @@ -1157,6 +1180,18 @@ export class App { 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; @@ -1175,12 +1210,16 @@ export class App { }); } + 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 { - fs.writeFileSync(p, this.buffer.text, "utf8"); - this.path = path.resolve(p); + this.saveToPath(p); this.flash("Saved " + p, 2000); } catch (e) { this.flash("Save failed: " + errorMessage(e), 3000); @@ -1189,49 +1228,264 @@ export class App { }); } + 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() { - this.modal = {type: "help"}; + 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" && (ev.name === "escape" || (ev.ctrl && ev.name === "k"))) { - this.modal = null; - this.dirty = true; - } else if (ev.type === "mouse" && ev.kind === "press") { - this.modal = null; - this.dirty = true; + 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 w = Math.min(72, this.cols - 8); - const h = Math.min(this.rows - 4, 22); - 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, " Recho · Quick help ", COLORS); - const lines = [ - "", - ` ${fg(COLORS.fn)}A reactive notebook in your terminal.${reset}`, - "", - ` Write JavaScript and call ${fg(COLORS.keyword)}echo${reset}(value).`, - ` Outputs render as ${fg(COLORS.output)}//➜ comments${reset} above each cell.`, - "", - ` ${bold}Editing${reset}`, - ` arrows / home / end / pageup / pagedown move`, - ` shift + movement select`, - ` alt + ←/→ word jumps`, - ` mouse click / drag / wheel navigate`, - "", - ` ${bold}Notebook${reset}`, - ` ^S Run ^X Stop ^R Restart runtime`, - ` ^E Examples ^O Open file ^W Save`, - ` ^L Console ^K This help ^Q Quit`, + 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, "", - ` ${fg(COLORS.dimText)}Click anywhere to dismiss.${reset}`, - ]; - for (let i = 0; i < lines.length && i < h - 2; i++) { - this.grid.writeStyled(top + 1 + i, left + 2, lines[i], ""); + ); + + 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); } } @@ -1391,6 +1645,34 @@ function cloneGrid(g: scr.Grid): scr.Grid { 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; diff --git a/terminal/cli.ts b/terminal/cli.ts index 92a2421..43e0fde 100755 --- a/terminal/cli.ts +++ b/terminal/cli.ts @@ -16,6 +16,7 @@ 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. @@ -30,6 +31,7 @@ for (let i = 1; i <= 5; 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() { @@ -59,7 +61,7 @@ function main() { process.exit(1); } - const app = new App({initialPath, initialCode, examplesDir}); + const app = new App({initialPath, initialCode, examplesDir, docsDir}); app.start(); } @@ -74,7 +76,8 @@ function printHelp() { "", "Inside the editor:", " ^S run ^X stop ^R restart runtime", - " ^E examples ^O open file ^W save", + " ^E examples ^N new file ^O open file", + " ^W save ^T rename file ^L console", " ^K help ^Q quit", "", ].join("\n"), 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 index 157c738..63ecc25 100644 --- a/terminal/highlight.ts +++ b/terminal/highlight.ts @@ -81,61 +81,135 @@ const KEYWORDS = new Set([ "await", ]); -const TOKEN_RE = new RegExp( - [ - String.raw`\/\/.*$`, // line comment (until end) - String.raw`\/\*[\s\S]*?\*\/`, // block comment - String.raw`"(?:\\.|[^"\\])*"`, // double-quoted string - String.raw`'(?:\\.|[^'\\])*'`, // single-quoted string - String.raw`\`(?:\\.|[^\\\`])*\``, // template literal (no expressions support) - String.raw`\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b`, // number - String.raw`\b[A-Za-z_$][\w$]*\b`, // identifier - String.raw`[{}()[\];,.<>:?!+\-*/%=&|^~]`, - ].join("|"), - "g", -); +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): string { +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) + italic + line + reset; + return fg(C.output) + line + reset; } if (trimmed.startsWith(ERROR_PREFIX) || trimmed.startsWith("//✗")) { - return fg(C.error) + italic + line + reset; + return fg(C.error) + line + reset; } if (trimmed.startsWith("//")) { return fg(C.comment) + italic + line + reset; } let out = fg(C.fg); - let last = 0; - TOKEN_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = TOKEN_RE.exec(line)) !== null) { - if (m.index > last) { - out += line.slice(last, m.index); + 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++; } - const tok = m[0]; - let style: string; - if (tok.startsWith("//")) style = fg(C.comment) + italic; - else if (tok.startsWith("/*")) style = fg(C.comment) + italic; - else 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)) { - // Function-call heuristic: identifier followed by '('. - const next = line[m.index + tok.length]; - if (next === "(") style = fg(C.fn); - else style = fg(C.fg); - } else style = fg(C.punct); - - out += style + tok + reset + fg(C.fg); - last = m.index + tok.length; } - if (last < line.length) out += line.slice(last); + out += reset; return out; } 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"); + }); +});