feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui

Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
This commit is contained in:
alt-glitch 2026-06-09 16:16:48 +00:00
parent 25567919ea
commit ae11a636dc
85 changed files with 5613 additions and 900 deletions

View file

@ -1560,14 +1560,15 @@ def _resolve_tui_engine() -> str:
"""Which TUI engine to launch: "ink" (default) or "opentui".
Precedence: ``HERMES_TUI_ENGINE`` env > ``display.tui_engine`` config >
(OpenTUI when this host can run it Bun + the installed package else Ink).
The OpenTUI engine runs on Bun, which is not reliably available on Windows
or Termux request "opentui" there falls back to "ink" with a notice so a
stale flag never strands the user on an engine that can't start.
(OpenTUI when this host can run it Node >= 26.3 + the built package else Ink).
The OpenTUI engine runs on Node 26.3+ via the experimental ``node:ffi`` renderer,
which is not validated on Windows or Termux a request for "opentui" there falls
back to "ink" with a notice so a stale flag never strands the user on an engine
that can't start.
"""
env = (os.environ.get("HERMES_TUI_ENGINE") or "").strip().lower()
# Explicit choice (env > config) wins; otherwise default to OpenTUI when this
# host is genuinely set up for it (Bun + the installed package), else Ink.
# host is genuinely set up for it (Node >= 26.3 + the built bundle), else Ink.
engine = env or _config_tui_engine_early() or ("opentui" if _opentui_available() else "ink")
if engine != "opentui":
return "ink"
@ -1579,124 +1580,172 @@ def _resolve_tui_engine() -> str:
where = "Windows" if sys.platform.startswith("win") else "Termux"
print(
f"HERMES_TUI_ENGINE=opentui is not supported on {where} "
f"(needs Bun) — falling back to the Ink engine.",
f"(needs Node 26.3+ with experimental FFI) — falling back to the Ink engine.",
file=sys.stderr,
)
return "ink"
return "opentui"
def _bun_bin_or_none() -> str | None:
"""Resolve the Bun binary, or ``None`` if not found (no exit — a probe).
NODE26_MIN_VERSION = (26, 3, 0)
``HERMES_BUN`` override > ``bun`` on PATH > common install dirs.
def _node_version_tuple(node_bin: str) -> tuple[int, int, int] | None:
"""Return (major, minor, patch) for a node binary, or ``None`` if unreadable."""
try:
out = subprocess.run([node_bin, "--version"], capture_output=True, text=True, timeout=5)
except Exception:
return None
if out.returncode != 0:
return None
raw = (out.stdout or "").strip().lstrip("v").split("-", 1)[0]
parts = raw.split(".")
try:
return (int(parts[0]), int(parts[1]), int(parts[2]))
except (IndexError, ValueError):
return None
def _node26_bin_or_none() -> str | None:
"""Resolve a Node >= 26.3.0 binary (no exit — a probe), or ``None``.
``HERMES_NODE`` override > ``node`` on PATH, each gated on version >= 26.3.0.
OpenTUI's native renderer loads via the experimental ``node:ffi`` API that only
exists on Node 26.3+, so an older Node is treated as "not available".
"""
env_bun = os.environ.get("HERMES_BUN")
if env_bun and os.path.isfile(env_bun) and os.access(env_bun, os.X_OK):
return env_bun
path = shutil.which("bun")
candidates: list[str] = []
env_node = os.environ.get("HERMES_NODE")
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
candidates.append(env_node)
path = shutil.which("node")
if path:
return path
for cand in (
Path.home() / ".bun" / "bin" / "bun",
Path("/usr/local/bin/bun"),
Path("/opt/homebrew/bin/bun"),
):
if cand.is_file() and os.access(cand, os.X_OK):
return str(cand)
candidates.append(path)
for cand in candidates:
ver = _node_version_tuple(cand)
if ver is not None and ver >= NODE26_MIN_VERSION:
return cand
return None
def _bun_bin() -> str:
"""Resolve the Bun binary for the OpenTUI engine, or exit with a clear message.
def _node26_bin() -> str:
"""Resolve Node >= 26.3.0 for the OpenTUI engine, or exit with a clear message.
The OpenTUI engine cannot run without Bun, so the launch path exits(1) when
it's missing. Use :func:`_bun_bin_or_none` for a non-fatal availability probe.
Use :func:`_node26_bin_or_none` for a non-fatal availability probe.
"""
bun = _bun_bin_or_none()
if bun is not None:
return bun
node = _node26_bin_or_none()
if node is not None:
return node
print(
"bun not found — the OpenTUI TUI engine requires Bun.\n"
"Install it (https://bun.sh) or set HERMES_BUN=/path/to/bun, "
"Node.js >= 26.3.0 not found — the OpenTUI TUI engine needs it for the "
"experimental node:ffi renderer.\n"
"Install Node 26.3+ (e.g. via fnm/nvm) or set HERMES_NODE=/path/to/node, "
"or unset HERMES_TUI_ENGINE to use the default Ink engine.",
file=sys.stderr,
)
sys.exit(1)
def _opentui_npm() -> str:
"""Resolve npm (ships with Node) to build the OpenTUI bundle, or exit."""
npm = shutil.which("npm")
if npm:
return npm
print(
"npm not found — needed to build the OpenTUI engine bundle.\n"
"Install Node 26.3+ (it ships npm), or unset HERMES_TUI_ENGINE for Ink.",
file=sys.stderr,
)
sys.exit(1)
def _opentui_available() -> bool:
"""Whether the OpenTUI engine can actually launch on this host.
True only when the platform supports Bun (not Windows/Termux), Bun resolves,
AND the v2 package's entry + its installed ``node_modules`` are present. This
gates the DEFAULT engine: a host that's genuinely set up for OpenTUI defaults
to it; everyone else stays on Ink. An explicit ``HERMES_TUI_ENGINE`` env or
``display.tui_engine`` config choice bypasses this probe entirely.
True only when the platform is supported (not Windows/Termux), a Node >= 26.3
binary resolves (the node:ffi floor), AND the v2 package is BUILT
(``dist/main.js``) with its ``node_modules`` installed. This gates the DEFAULT
engine: a host genuinely set up for OpenTUI defaults to it; everyone else stays
on Ink. An explicit ``HERMES_TUI_ENGINE`` env or ``display.tui_engine`` config
choice bypasses this probe (and triggers an on-demand build).
"""
if sys.platform.startswith("win") or _is_termux_startup_environment():
return False
if _bun_bin_or_none() is None:
if _node26_bin_or_none() is None:
return False
pkg = PROJECT_ROOT / "ui-tui-opentui-v2"
entry = pkg / "src" / "entry" / "main.tsx"
return entry.is_file() and (pkg / "node_modules" / "@opentui").is_dir()
pkg = PROJECT_ROOT / "ui-opentui"
built = pkg / "dist" / "main.js"
return built.is_file() and (pkg / "node_modules" / "@opentui").is_dir()
def _make_opentui_argv(tui_dev: bool) -> tuple[list[str], Path]:
"""Argv for the native OpenTUI engine (Bun runs the TypeScript entry directly).
"""Argv for the native OpenTUI engine under Node 26 (no Bun).
Prefers the v4 Solid + Effect-at-boundary engine
(``ui-tui-opentui-v2/src/entry/main.tsx``); falls back to the superseded React
build (``ui-tui-opentui/src/entry.real.tsx``) if the v2 package isn't present.
Returns the argv and the package cwd. ``tui_dev`` adds ``--watch`` for hot reload.
Builds the Solid + Effect-at-boundary engine (``ui-opentui``) with esbuild
(``npm run build`` ``dist/main.js``) when the bundle is missing (or always, in
``--dev``), then launches it on Node with the experimental FFI flag:
The spawned ``tui_gateway`` resolves its Python from this checkout
(``HERMES_PYTHON_SRC_ROOT`` env ``<root>/.venv`` ); since the package lives at
``<root>/ui-tui-opentui-v2`` the gateway's default source-root resolution lands on
``PROJECT_ROOT`` without extra plumbing.
node --experimental-ffi --no-warnings dist/main.js
``--no-warnings`` keeps the ExperimentalWarning off the TUI's stderr. Returns the
argv and the package cwd.
The spawned ``tui_gateway`` resolves its Python from ``HERMES_PYTHON_SRC_ROOT``
(the caller sets it to ``PROJECT_ROOT``); the built bundle's own fallback also
walks up to the checkout root, so the gateway resolves correctly either way.
"""
candidates = (
(PROJECT_ROOT / "ui-tui-opentui-v2", Path("src") / "entry" / "main.tsx"),
(PROJECT_ROOT / "ui-tui-opentui", Path("src") / "entry.real.tsx"),
)
for app_dir, rel in candidates:
entry = app_dir / rel
if entry.is_file():
# Preflight: Bun runs the TS entry directly (no build step), so the
# package's node_modules must already be installed or the first
# `import '@opentui/...'` dies with a cryptic resolve error and a blank
# UI. Fail loudly with the fix instead.
if not (app_dir / "node_modules" / "@opentui").is_dir():
print(
f"OpenTUI engine dependencies are not installed in {app_dir}.\n"
f"Run: (cd {app_dir} && bun install)\n"
f"Or unset HERMES_TUI_ENGINE to use the default Ink engine.",
file=sys.stderr,
)
sys.exit(1)
bun = _bun_bin()
args = [bun]
if tui_dev:
args.append("--watch")
args.append(str(entry))
return args, app_dir
app_dir = PROJECT_ROOT / "ui-opentui"
entry_src = app_dir / "src" / "entry" / "main.tsx"
if not entry_src.is_file():
print(
f"OpenTUI v2 engine entry not found at {entry_src}.\n"
f"Unset HERMES_TUI_ENGINE to use the default Ink engine.",
file=sys.stderr,
)
sys.exit(1)
tried = ", ".join(str(app_dir / rel) for app_dir, rel in candidates)
print(
f"OpenTUI engine entry not found (tried: {tried}).\n"
f"Unset HERMES_TUI_ENGINE to use the default Ink engine.",
file=sys.stderr,
)
sys.exit(1)
node = _node26_bin()
# The esbuild build needs the package's node_modules (esbuild + the @opentui
# packages + the native blob). Without them the build/launch dies cryptically.
if not (app_dir / "node_modules" / "@opentui").is_dir():
print(
f"OpenTUI engine dependencies are not installed in {app_dir}.\n"
f"Run: (cd {app_dir} && npm install)\n"
f"Or unset HERMES_TUI_ENGINE to use the default Ink engine.",
file=sys.stderr,
)
sys.exit(1)
built = app_dir / "dist" / "main.js"
if tui_dev or not built.is_file():
npm = _opentui_npm()
if not os.environ.get("HERMES_QUIET"):
print("Building the OpenTUI engine…", file=sys.stderr)
result = subprocess.run(
[npm, "run", "build"],
cwd=str(app_dir),
capture_output=True,
text=True,
)
if result.returncode != 0:
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
preview = "\n".join(combined.splitlines()[-30:])
print("OpenTUI engine build failed.", file=sys.stderr)
if preview:
print(preview, file=sys.stderr)
sys.exit(1)
return [node, "--experimental-ffi", "--no-warnings", str(built)], app_dir
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).
Dual-engine: when ``HERMES_TUI_ENGINE``/``display.tui_engine`` selects the
native OpenTUI engine, dispatch to ``_make_opentui_argv`` (Bun) BEFORE any
Node bootstrap a Bun-only host must not be forced through ``_ensure_tui_node``.
native OpenTUI engine, dispatch to ``_make_opentui_argv`` (Node 26 + its own
esbuild build) BEFORE the Ink Node bootstrap the OpenTUI engine resolves its
own Node >= 26.3 and builds its own bundle, so it must not be routed through
``_ensure_tui_node`` / the Ink prebuilt-dir logic.
"""
if _resolve_tui_engine() == "opentui":
return _make_opentui_argv(tui_dev)
@ -2060,14 +2109,14 @@ def _launch_tui(
# ("--expose-gc is not allowed in NODE_OPTIONS") and refuses to start.
# It is passed as a direct argv flag in _make_tui_argv() instead.
#
# ENGINE-GATED: --max-old-space-size is a V8/Node flag. The OpenTUI engine
# runs on Bun (JavaScriptCore), which has no such flag and would error/ignore
# it — so only apply the Node heap sizing for the Ink engine.
if _resolve_tui_engine() == "ink":
_tokens = env.get("NODE_OPTIONS", "").split()
if not any(t.startswith("--max-old-space-size=") for t in _tokens):
_tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}")
env["NODE_OPTIONS"] = " ".join(_tokens)
# Both TUI engines run on Node/V8 now — Ink, and the native OpenTUI engine
# (Node 26 + node:ffi). So --max-old-space-size (a V8/Node flag) applies to
# both. (Pre-Node-26 the OpenTUI engine ran on Bun/JavaScriptCore, which has
# no such flag; that gate is gone now that the engine is Node.)
_tokens = env.get("NODE_OPTIONS", "").split()
if not any(t.startswith("--max-old-space-size=") for t in _tokens):
_tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}")
env["NODE_OPTIONS"] = " ".join(_tokens)
# HERMES_TUI_RESUME is an internal hand-off from the Python wrapper to the
# Ink app. Because we start from os.environ.copy(), an exported/stale value
# in the user's shell would otherwise make a plain `hermes --tui` try to