Skip to content

Add the terminal user interface - #212

Open
chengluyu wants to merge 25 commits into
mainfrom
feat/tui
Open

Add the terminal user interface#212
chengluyu wants to merge 25 commits into
mainfrom
feat/tui

Conversation

@chengluyu

Copy link
Copy Markdown
Collaborator

No description provided.

chengluyu and others added 8 commits April 26, 2026 20:04
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 <noreply@anthropic.com>
…e UI

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
recho-notebook Ready Ready Preview Sep 1, 2026 6:07am UTC

chengluyu and others added 4 commits August 30, 2026 20:59
…52c3c7

# Conflicts:
#	pnpm-lock.yaml
#	runtime/index.js
The runtime worker loads runtime/worker.ts directly as a worker-thread
entry, and the terminal CLI runs terminal/cli.ts with plain node — both
rely on Node's built-in type stripping, which Node 20 lacks (the two
workerRuntime tests time out there). Bump CI to the current LTS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The examples picker painted each list row one column too wide (its last
cell landed on the right border), drew unselected rows with the terminal
default background instead of the box interior background, and wrote the
help line on the bottom border row — so the list visually punched through
the frame. Rows now span exactly the box interior with the interior
background, long names and the filter query are truncated to fit, and the
help line moved to the last interior row. Same border-row and overflow
fixes applied to the input prompt modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
runtime/index.js is shared between the TUI worker and the browser editor;
the static `import vm from "node:vm"` made webpack fail the Next.js
build (Vercel deploy) with an unhandled node: scheme. Fetch vm via
process.getBuiltinModule (Node 22.3+) instead — synchronous in Node,
absent in the browser, where safeEval already falls back to new Function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
runtime/index.js is bundled for the browser, so node:vm has no business
in it — not even behind process.getBuiltinModule. createRuntime now takes
a compileCell hook (default: plain new Function, the browser path), and
the vm machinery — timeout-armed execution, CellTimeoutError, the realm
rationale — lives in the new Node-only runtime/vmEval.js, which only the
TUI worker imports and injects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The render loop ticked at a fixed 80ms, capping animated notebooks at
~12.5fps and adding up to 80ms of typing latency. The tick now
self-schedules: ~60fps while runtime changes streamed in within the last
250ms, back to the lazy 80ms once the stream goes quiet. Keystrokes
render immediately after being handled instead of waiting for a tick,
and the run spinner is time-gated so the fast cadence doesn't speed it
up. Renders cost ~0.2-0.4ms, so the fast cadence stays well under 5%
of a core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pnpm perf boots the real App with the real worker runtime, runs an
animated example (matrix-rain by default), and measures the full
change→render pipeline: worker change throughput, render cadence and
cost, and terminal bytes written. Generous sanity gates catch
regressions in kind — a stalled animation, renders lagging the change
stream, expensive frames, or runtime errors — without flaking on
machine load. Kept out of pnpm test since timings are load-dependent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The palette was hard-coded for dark terminals, so on a light background
the dim grays and pale token colors were barely readable. The CLI now
detects the background before the first paint — RECHO_THEME env override,
then an OSC 11 background-color query (luminance decides), then
COLORFGBG, defaulting to dark — and setTheme swaps the palette in place.
The light palette uses dark-on-light analogs tuned for contrast (e.g.
the pale-yellow number color becomes mid-brown). Header, status bar,
modal interior, and selection colors moved from hard-coded indices into
the palette so both themes cover all chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two complementary changes. The runtime now trims every output-block
replacement to the span that actually differs from the current text
(identical frames dispatch nothing, and the worker skips the IPC for
empty batches), computed after block metadata so effects are untouched.
And the TUI buffer restores a cursor or selection anchor that sits
inside a replaced range to the same row/col relative to that range,
instead of clamping it to the range end — so the cursor can rest inside
a live //➜ block while animations rewrite it every frame.

Verified end-to-end: with matrix-rain running, a cursor placed mid-block
holds its exact position across 2s of frames while the line under it
keeps animating; pnpm perf shows unchanged throughput (14 changes/s,
~23 renders/s, 0.7ms avg render).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chengluyu and others added 3 commits August 31, 2026 12:02
//➜ and //✗ lines get a faint background band across the editor width
(dark: 234, light: 255) so runtime output reads as distinct from source.
Painted before the cursor-line overlay so the cursor line stays visible
inside output blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header's top-right corner shows the runtime change rate (batches in
the last second) while an animation is producing output — matrix-rain
reads 15fps, matching its interval. Entries age out within a second, so
the indicator disappears when the notebook goes quiet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gutter grows one column (GUTTER 5 → 6) to fit a │ divider between
line numbers and code, in the border color; filler rows draw it too.
All coordinate math (mouse mapping, clipping, scroll) keys off the
GUTTER constant, so it follows automatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Node-based terminal interface for editing and running Recho notebooks.

Changes:

  • Adds the ANSI TUI, worker runtime, themes, help, file actions, and console.
  • Extends runtime compilation and Node module loading.
  • Adds terminal tests, dependencies, type checking, and Node 24 CI.

Reviewed changes

Copilot reviewed 39 out of 42 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
terminal/app.ts Implements the TUI application.
terminal/buffer.ts Adds document and cursor management.
terminal/cli.ts Adds the CLI entrypoint.
terminal/docs.ts Loads terminal help content.
terminal/highlight.ts Adds syntax highlighting and palettes.
terminal/screen.ts Implements terminal input and rendering.
terminal/theme.ts Detects terminal themes.
terminal/workerRuntime.ts Proxies runtime workers.
runtime/worker.ts Adds the worker entrypoint.
runtime/vmEval.js Adds timeout-controlled evaluation.
runtime/index.js Adds compiler injection and change trimming.
runtime/stdlib/index.js Adds Node module loading.
runtime/controls/index.js Adds explicit module extensions.
editor/blocks/BlockMetadata.ts Makes fields type-strip compatible.
types/d3-dispatch.d.ts Adds dispatch declarations.
package.json Exposes the CLI and updates tooling.
pnpm-lock.yaml Updates dependency resolution.
eslint.config.mjs Includes terminal TypeScript.
.github/workflows/test.yml Moves CI to Node 24.
.gitignore Ignores TypeScript build metadata.
test/workerRuntime.spec.js Tests worker output and restart.
test/terminalTheme.spec.js Tests theme behavior.
test/terminalScrollbar.spec.js Tests scrollbar behavior.
test/terminalHighlight.spec.js Tests terminal highlighting.
test/terminalHelp.spec.js Tests help navigation.
test/terminalFileActions.spec.js Tests file operations.
test/terminalCursorStability.spec.js Tests runtime edit mapping.
test/stdlib.spec.js Tests Node module loading.
test/perf/tui-perf.mjs Adds a TUI performance harness.
test/js/random-histogram.js Uses granular D3 modules.
test/js/matrix-rain.js Uses granular D3 modules.
app/examples/sorting.recho.js Updates D3 imports.
app/examples/random-histogram.recho.js Updates D3 imports.
app/examples/phases-of-the-moon.recho.js Updates D3 imports.
app/examples/moon-sundial.recho.js Updates D3 imports.
app/examples/ml5-handpose.recho.js Updates D3 imports.
app/examples/matrix-rain.recho.js Updates D3 imports.
app/examples/fire!.recho.js Updates D3 imports.
app/examples/animals-isotype-chart.recho.js Updates imports and documentation.
app/examples/abacus.recho.js Updates D3 imports.
app/docs/getting-started.recho.js Updates import documentation.
app/docs/aynchronous-operations.recho.js Updates the D3 example.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread terminal/app.ts Outdated
Comment thread terminal/screen.ts Outdated
Comment thread runtime/stdlib/index.js
Comment on lines +24 to +28
function nodeImport(name) {
if (typeof name !== "string") return Promise.resolve(name);
let module = nodeImportCache.get(name);
if (!module) {
module = import(name).then(normalizeModule);
Comment thread terminal/app.ts
Comment thread terminal/app.ts
Comment thread package.json
Comment on lines +28 to +30
"bin": {
"recho": "./terminal/cli.ts"
},
Comment thread terminal/app.ts Outdated
chengluyu and others added 5 commits September 1, 2026 06:18
The settle check compared total console length against the index
recorded at run start, so any console.log or warn during a run made
a successful notebook report as failed. lastErrorAfter already finds
error-level entries since the run began — use its result directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename moved the file first and wrote the buffer second, so a failed
write reported an error after the original path was already gone, and
cross-device targets failed outright. Save to the target first, then
remove the old path, and report a leftover old file distinctly from a
failed rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Browsing an example assigned the bundled file's path to the document,
so a later ^S silently overwrote the installed example and Rename
could move it out of the package. Keep the buffer untitled and carry
the example name as a suggestion for the save prompt and title bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parseInput had no carry-over state, so a CSI, SS3, or SGR mouse
sequence split across data events was lost or leaked its tail into
the buffer as text. It now returns the unconsumed suffix, which
onInput prepends to the next chunk. A trailing lone ESC is held
briefly and flushed as a real Escape press if no continuation
arrives. Modal routing moved from per-chunk to per-event so keys
after a modal opens mid-batch reach the right handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An exception inside the tick callback became an uncaughtException,
which the process handler swallowed — so the loop never rescheduled,
leaving a frozen alternate screen with raw stdin. The tick now guards
its body: a stray failure is logged to the console panel and the loop
keeps going, while three consecutive failures (a broken render path)
escalate to onFatal so the terminal is restored before exiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants