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
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Single acceptance command for the Bun→Node-26 switchover (see
# docs/plans/opentui-node26-build-spec.md). Proves, on a Node 26.3 host, that the
# OpenTUI v2 engine runs WITHOUT Bun and at parity:
#
# 1. Node >= 26.3 present (the node:ffi floor); reports whether bun is on PATH
# (the engine must NOT need it).
# 2. `npm run check` — prettier + tsc + eslint + vitest (151+), all on Node.
# 3. live-gateway transport smoke — spawns the real Python tui_gateway via the
# node:child_process client, asserts gateway.ready + session.create.
# (Skipped if no Hermes venv resolves — CI parity.)
# 4. selection/markdown smoke in a real tmux TTY — asserts the native <markdown>
# (Tree-sitter) PAINTS under node --experimental-ffi and that a selection
# copies the RAW markdown source. (Skipped if tmux is unavailable.)
#
# Run: cd ui-opentui && HERMES_PYTHON_SRC_ROOT=<checkout-root> bash scripts/acceptance.sh
set -uo pipefail
cd "$(dirname "$0")/.."
# Absolute node, so a fresh tmux pane (which won't inherit our PATH / fnm shim)
# runs the SAME Node 26.3, not the shell's default.
NODE_BIN="$(command -v node || echo node)"
pass=0; fail=0; skip=0
ok() { echo "$1"; pass=$((pass+1)); }
bad() { echo "$1"; fail=$((fail+1)); }
note() { echo "$1"; skip=$((skip+1)); }
echo "== [1/4] runtime: Node >= 26.3, Bun-free =="
NODE_V="$(node -p 'process.versions.node' 2>/dev/null || echo 0.0.0)"
node -e 'const [a,b]=process.versions.node.split(".").map(Number); process.exit(a>26||(a===26&&b>=3)?0:1)' \
&& ok "node $NODE_V (>= 26.3)" || bad "node $NODE_V is below the 26.3 node:ffi floor"
if command -v bun >/dev/null 2>&1; then
note "bun is on PATH ($(command -v bun)) — fine; the engine does not use it (proven below)"
else
ok "no bun on PATH — single-runtime host"
fi
echo "== [2/4] check: prettier + tsc + eslint + vitest =="
if bash scripts/check.sh >/tmp/accept-check.log 2>&1; then ok "check green ($(grep -c 'passed' /tmp/accept-check.log >/dev/null 2>&1; grep -oE '[0-9]+ passed' /tmp/accept-check.log | tail -1))"
else bad "check failed — see /tmp/accept-check.log"; tail -20 /tmp/accept-check.log; fi
echo "== [3/4] live-gateway transport smoke (real Python gateway, no Bun) =="
if [ -n "${HERMES_PYTHON_SRC_ROOT:-}" ] || [ -x "../.venv/bin/python" ]; then
rm -rf .accept && node scripts/build.mjs src/test/liveGateway.smoke.ts .accept >/dev/null 2>&1
OUT="$(node --experimental-ffi --no-warnings .accept/liveGateway.smoke.js 2>&1)"
echo "$OUT" | grep -q "^PASS" && ok "$(echo "$OUT" | grep '^PASS')" || { echo "$OUT" | grep -qE "TRANSPORT ERROR|SKIP" && note "gateway smoke skipped (no python/model)" || bad "gateway smoke: $(echo "$OUT" | head -1)"; }
rm -rf .accept
else
note "no HERMES_PYTHON_SRC_ROOT / venv — gateway smoke skipped"
fi
echo "== [4/4] selection/markdown smoke in a real tmux TTY (tree-sitter under FFI) =="
if command -v tmux >/dev/null 2>&1; then
rm -rf .accept && node scripts/build.mjs src/test/selectionCopy.smoke.tsx .accept >/dev/null 2>&1
rm -f /tmp/accept-sel.json
S="accept-$$"
tmux kill-session -t "$S" 2>/dev/null
tmux new-session -d -s "$S" -x 120 -y 40
tmux send-keys -t "$S" "SEL_SMOKE_OUT=/tmp/accept-sel.json $NODE_BIN --experimental-ffi --no-warnings $PWD/.accept/selectionCopy.smoke.js; tmux wait-for -S $S" Enter
tmux wait-for "$S" 2>/dev/null || sleep 6
tmux kill-session -t "$S" 2>/dev/null
if node -e 'process.exit(require("/tmp/accept-sel.json").pass===true?0:1)' 2>/dev/null; then
ok "markdown painted + selection copied source (tree-sitter under node FFI)"
else
bad "selection/markdown smoke failed — see /tmp/accept-sel.json"; cat /tmp/accept-sel.json 2>/dev/null
fi
rm -rf .accept
else
note "tmux not available — markdown smoke skipped (run it on a TTY host)"
fi
echo
echo "== acceptance: $pass passed, $fail failed, $skip skipped =="
[ "$fail" -eq 0 ] && { echo "ACCEPTANCE: PASS"; exit 0; } || { echo "ACCEPTANCE: FAIL"; exit 1; }
+75
View File
@@ -0,0 +1,75 @@
/**
* Build the OpenTUI v2 Solid app for Node 26 (no Bun).
*
* Mirrors OpenTUI's own Node recipe (`~/github/opentui/.../run-node26.mjs` +
* `packages/solid/scripts/solid-transform.ts`): apply babel-preset-solid in
* `generate:"universal"` mode with `moduleName:"@opentui/solid"` to every app
* .tsx/.jsx, and force solid-js to its CLIENT/universal build (the package's
* `node` export condition points at the SSR `server.js`, which lacks the
* reactive primitives the universal renderer needs).
*
* `@opentui/core` stays EXTERNAL: it resolves its per-arch native `libopentui.so`
* (and the tree-sitter worker) from its own package dir via `import.meta.url`;
* bundling it would break those paths.
*
* Run with the Node that will launch the app:
* node scripts/build.mjs # → dist/main.js (app entry)
* node scripts/build.mjs <entry.tsx> <outdir> # build an arbitrary entry (smokes/spikes)
* Launch:
* node --experimental-ffi --no-warnings dist/main.js
*/
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { transformAsync } from '@babel/core'
import tsPreset from '@babel/preset-typescript'
import solidPreset from 'babel-preset-solid'
import * as esbuild from 'esbuild'
const require = createRequire(import.meta.url)
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/** esbuild plugin that reproduces @opentui/solid's transform + solid-js resolution. */
const opentuiSolid = {
name: 'opentui-solid',
setup(build) {
// App JSX (.tsx/.jsx, never node_modules) → babel-preset-solid (universal).
build.onLoad({ filter: /\.[cm]?[jt]sx$/ }, async args => {
if (args.path.includes('/node_modules/')) return null
const code = await readFile(args.path, 'utf8')
const out = await transformAsync(code, {
filename: args.path,
configFile: false,
babelrc: false,
presets: [[solidPreset, { moduleName: '@opentui/solid', generate: 'universal' }], [tsPreset]]
})
return { contents: out?.code ?? '', loader: 'js' }
})
// Force the universal/client solid-js build (node condition → server.js otherwise).
build.onResolve({ filter: /^solid-js$/ }, () => ({ path: require.resolve('solid-js/dist/solid.js') }))
build.onResolve({ filter: /^solid-js\/store$/ }, () => ({ path: require.resolve('solid-js/store/dist/store.js') }))
}
}
const [, , entryArg, outdirArg] = process.argv
const entry = entryArg ? resolve(process.cwd(), entryArg) : resolve(root, 'src/entry/main.tsx')
const outdir = outdirArg ? resolve(process.cwd(), outdirArg) : resolve(root, 'dist')
await esbuild.build({
entryPoints: [entry],
outdir,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node26',
splitting: true,
sourcemap: true,
logLevel: 'info',
// Native blob + tree-sitter worker resolve from @opentui/core's own dir at runtime.
external: ['@opentui/core', '@opentui/core/*'],
plugins: [opentuiSolid],
define: { 'process.env.OPENTUI_BUN_ONLY_EXAMPLES': '"false"' }
})
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Phase gate for the native OpenTUI engine (spec v4 §5). Runs the full headless
# suite: format + type-check + lint + vitest (which includes the headless frame
# gate via captureCharFrame). The agentic smoke (docs/plans/opentui-smoke.md) is
# the live complement — run BOTH every phase.
#
# Runs entirely on Node 26.3 (no Bun). The OpenTUI native core loads via node:ffi
# under --experimental-ffi; vitest passes that flag to its test forks (see
# vitest.config.ts). Requires `node -v` == v26.3.x on PATH.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== [1/4] format (prettier --check) =="
npx prettier --check src
echo "== [2/4] type-check =="
npm run --silent type-check
echo "== [3/4] lint =="
npm run --silent lint
echo "== [4/4] vitest (incl. headless frame gate) =="
npm test
echo "== check OK =="
+48
View File
@@ -0,0 +1,48 @@
/**
* DEV DEMO — NOT a test, NOT production. Renders the bench fixture (lorem-ipsum +
* fat tool-turns from ./fixture.ts) in a REAL CliRenderer so you can attach over
* tmux, scroll, and eyeball the transcript + the rolling-cap truncation notice.
* No gateway is spawned (purely the fixture seeded into the store via the resume
* path), so typing won't reach a backend — it's for viewing/scrolling.
*
* Run (Node 26 — needs the esbuild/Solid transform, then --experimental-ffi):
* node scripts/build.mjs scripts/demo.tsx .demo
* node --experimental-ffi --no-warnings .demo/demo.js # inside tmux (needs a TTY)
* DEMO_TOTAL=200 fixture messages to seed (default 200)
* HERMES_TUI_MAX_MESSAGES=80 cap → the "⤒ N earlier messages" notice fires
* Quit: Ctrl+C.
*/
import { createCliRenderer } from '@opentui/core'
import { render } from '@opentui/solid'
import { createSessionStore } from '../src/logic/store.ts'
import { App } from '../src/view/App.tsx'
import { ThemeProvider } from '../src/view/theme.tsx'
import { materialize } from './fixture.ts'
const TOTAL = Number.parseInt(process.env.DEMO_TOTAL ?? '', 10) || 200
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.setSessionId('demo-fixture-20260609')
// Seed via the resume path so the cap slices + the `dropped` counter is set
// (drives the truncation notice) exactly as a real `session.resume` would.
store.beginBuffer()
store.commitSnapshot(materialize(TOTAL))
const renderer = await createCliRenderer({
externalOutputMode: 'passthrough',
targetFps: 60,
exitOnCtrlC: true,
useKittyKeyboard: {},
useMouse: true
})
void render(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
renderer
)
+288
View File
@@ -0,0 +1,288 @@
/**
* DEV BENCH FIXTURE — NOT a test, NOT production code. A deterministic generator
* for a REALISTIC heavy session, consumed by `scripts/mem-bench.tsx`. Excluded
* from the vitest run (not a *.test.ts) and lint-clean.
*
* The old synthetic bench pushed tiny 3-delta turns (~5.5 mounted nodes each) —
* an unrealistic per-message cost. Real transcripts are LUMPY: an assistant turn
* is ONE `message` but a fat node subtree (markdown blocks + a reasoning block +
* several tool headers, each a multi-line result). That makes message-count a
* LOOSE proxy for memory, which is exactly what we're trying to quantify before
* picking a `HERMES_TUI_MAX_MESSAGES` default.
*
* Design: a turn is modeled as a small typed `TurnAction` union (user / system /
* gateway-event). The driver maps user→`pushUser`, system→`pushSystem`, and every
* gateway event through the SAME `apply()` reducer real usage takes — so the
* mounted result is identical to a live session. The same action stream also
* materializes a settled `Message[]` (via `materialize`) for the resume-path check
* (`commitSnapshot`). Everything is seeded by index (no `Math.random` —
* unavailable here), so a given `total` reproduces byte-for-byte.
*/
import type { GatewayEvent } from '../src/boundary/schema/GatewayEvent.ts'
import { createSessionStore, type Message } from '../src/logic/store.ts'
/** One scripted action in a turn: a composer push or a decoded gateway event. */
type TurnAction =
| { kind: 'user'; text: string }
| { kind: 'system'; text: string }
| { kind: 'event'; event: GatewayEvent }
/** A pool of lorem-ipsum words — varied content is selected by index from here. */
const WORDS = [
'lorem',
'ipsum',
'dolor',
'sit',
'amet',
'consectetur',
'adipiscing',
'elit',
'sed',
'eiusmod',
'tempor',
'incididunt',
'labore',
'magna',
'aliqua',
'enim',
'minim',
'veniam',
'quis',
'nostrud',
'exercitation',
'ullamco',
'laboris',
'aliquip',
'commodo',
'consequat',
'duis',
'aute',
'irure',
'reprehenderit',
'voluptate',
'velit',
'esse',
'cillum',
'fugiat',
'nulla',
'pariatur',
'excepteur',
'occaecat',
'cupidatat',
'proident',
'sunt',
'culpa',
'officia',
'deserunt',
'mollit',
'anim'
] as const
/** Deterministic pseudo-word stream: pick from WORDS by a seeded index. */
function word(seed: number, k: number): string {
return WORDS[(seed * 31 + k * 7) % WORDS.length] ?? 'lorem'
}
/** A lorem sentence of `n` words, capitalized + terminated. */
function sentence(seed: number, n: number): string {
const parts: string[] = []
for (let k = 0; k < n; k++) parts.push(word(seed + k, k))
const text = parts.join(' ')
return text.charAt(0).toUpperCase() + text.slice(1) + '.'
}
/** A paragraph of `s` sentences (varying length by index). */
function paragraph(seed: number, s: number): string {
const out: string[] = []
for (let i = 0; i < s; i++) out.push(sentence(seed + i * 13, 6 + ((seed + i) % 9)))
return out.join(' ')
}
/** N lorem-ipsum lines (for tool result bodies), each varying in length. */
function lines(seed: number, n: number): string {
const out: string[] = []
for (let i = 0; i < n; i++) out.push(sentence(seed + i * 5, 4 + ((seed + i) % 11)))
return out.join('\n')
}
/** A markdown assistant body: paragraphs + a list + a fenced code block. */
function assistantMarkdown(seed: number): string {
const lead = paragraph(seed, 1 + (seed % 3))
const bullets = [`- ${sentence(seed + 1, 5)}`, `- ${sentence(seed + 2, 7)}`, `- ${sentence(seed + 3, 4)}`].join('\n')
const code = [
'```ts',
`const x${seed % 7} = ${seed % 100}`,
`function f${seed % 5}() {`,
' return x',
'}',
'```'
].join('\n')
const tail = paragraph(seed + 17, 1 + ((seed + 1) % 2))
return `${lead}\n\n${bullets}\n\n${code}\n\n${tail}`
}
/** Tool names cycled by index (mirrors a real tool mix). */
const TOOL_NAMES = ['terminal', 'read_file', 'edit_file', 'grep', 'web_search', 'write_file'] as const
/** A tool.start + tool.complete pair for tool `t` in turn `seed`. */
function toolEvents(seed: number, t: number): GatewayEvent[] {
const id = `tool-${seed}-${t}`
const name = TOOL_NAMES[(seed + t) % TOOL_NAMES.length] ?? 'terminal'
const variant = (seed + t) % 3
// short / capped-16-line / medium result bodies, mixing the render-cost cases.
const bodyLines = variant === 0 ? 2 : variant === 1 ? 18 : 7
const resultText = lines(seed + t * 3, bodyLines)
const context = sentence(seed + t, 4)
// ~half the tools carry a multi-line args block (the expanded-view cost).
const withArgs = (seed + t) % 2 === 0
const start: GatewayEvent = {
type: 'tool.start',
payload: withArgs ? { tool_id: id, name, context, args_text: lines(seed + t, 5) } : { tool_id: id, name, context }
}
const complete: GatewayEvent = {
type: 'tool.complete',
payload: {
tool_id: id,
name,
result_text: resultText,
duration_s: 0.1 + ((seed + t) % 40) / 10,
args: { command: context, index: seed + t }
}
}
return [start, complete]
}
/** One USER message (14 lorem paragraphs; some very short, some RFC-sized). */
function userText(seed: number): string {
const shape = seed % 7
if (shape === 0) return 'yes do that'
if (shape === 1) return 'ok'
if (shape === 6) {
// an RFC-sized pasted block: many paragraphs.
const out: string[] = []
for (let p = 0; p < 8; p++) out.push(paragraph(seed + p * 23, 4 + (p % 3)))
return out.join('\n\n')
}
const n = 1 + (seed % 4)
const out: string[] = []
for (let p = 0; p < n; p++) out.push(paragraph(seed + p * 11, 1 + ((seed + p) % 3)))
return out.join('\n\n')
}
/**
* Build the scripted actions for ONE turn. Most turns are a plain user+assistant
* exchange; a deterministic subset are tool-heavy (115 tool calls) or a system
* slash-output line. Returns the actions for the whole turn in order.
*/
function turnActions(turn: number): TurnAction[] {
const actions: TurnAction[] = []
// Occasional system slash-output line (≈ every 9th turn) instead of a user line.
if (turn % 9 === 4) {
actions.push({ kind: 'system', text: sentence(turn, 8) })
return actions
}
actions.push({ kind: 'user', text: userText(turn) })
actions.push({ kind: 'event', event: { type: 'message.start' } })
// Reasoning on ≈ every 3rd assistant turn.
if (turn % 3 === 0) {
actions.push({
kind: 'event',
event: {
type: 'reasoning.delta',
payload: { text: `**${sentence(turn, 3).replace(/\.$/, '')}**\n\n${paragraph(turn + 5, 2)}` }
}
})
}
// Leading text part.
actions.push({ kind: 'event', event: { type: 'message.delta', payload: { text: assistantMarkdown(turn) } } })
// Tool-heavy turns: ≈ every 4th assistant turn carries several tool calls,
// interleaved with a follow-up text part (the fat-turn stress case).
if (turn % 4 === 0) {
const toolCount = 1 + (turn % 15) // 1..15 tools
for (let t = 0; t < toolCount; t++) {
for (const ev of toolEvents(turn, t)) actions.push({ kind: 'event', event: ev })
}
actions.push({ kind: 'event', event: { type: 'message.delta', payload: { text: paragraph(turn + 31, 2) } } })
}
actions.push({ kind: 'event', event: { type: 'message.complete' } })
return actions
}
/** How many transcript ROWS a turn produces (user/system + at most one assistant). */
export function rowsPerTurn(turn: number): number {
return turn % 9 === 4 ? 1 : 2
}
/** Apply ONE turn's actions to a store via the same paths real usage takes. */
export function applyTurn(store: ReturnType<typeof createSessionStore>, turn: number): void {
for (const action of turnActions(turn)) {
if (action.kind === 'user') store.pushUser(action.text)
else if (action.kind === 'system') store.pushSystem(action.text)
else store.apply(action.event)
}
}
/**
* Drive at least `total` MESSAGES into the live store, calling `onSample(pushes)`
* each time the cumulative produced-row count crosses a `sampleEvery` boundary.
* `pushes` counts MESSAGES (rows produced, pre-cap), so the matrix samples on a
* raw message cadence regardless of the rolling cap.
*/
export function drive(
store: ReturnType<typeof createSessionStore>,
total: number,
sampleEvery: number,
onSample: (pushes: number) => void
): number {
let pushed = 0
let nextSample = sampleEvery
let turn = 0
while (pushed < total) {
applyTurn(store, turn)
pushed += rowsPerTurn(turn)
turn++
while (pushed >= nextSample && nextSample <= total) {
onSample(Math.min(pushed, total))
nextSample += sampleEvery
}
}
return turn
}
/**
* Materialize the FULL settled `Message[]` for the resume path: replay the same
* action stream into a FRESH, EFFECTIVELY-UNCAPPED store and snapshot its rows.
* This guarantees the resume fixture is byte-identical to what the live push
* path produces (minus the rolling cap), so `commitSnapshot` mounts the real shape.
*/
export function materialize(total: number): Message[] {
const prev = process.env.HERMES_TUI_MAX_MESSAGES
process.env.HERMES_TUI_MAX_MESSAGES = String(Number.MAX_SAFE_INTEGER)
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
let pushed = 0
let turn = 0
while (pushed < total) {
applyTurn(store, turn)
pushed += rowsPerTurn(turn)
turn++
}
// Restore the env so the bench's own cap (read per-store) is unaffected.
if (prev === undefined) delete process.env.HERMES_TUI_MAX_MESSAGES
else process.env.HERMES_TUI_MAX_MESSAGES = prev
// Deep-copy out of the solid store proxy into plain objects (the resume path
// takes a plain Message[]).
return store.state.messages.slice(0, total).map(cloneMessage)
}
/** Plain deep copy of a store Message (drop the solid proxy + streaming flag). */
function cloneMessage(m: Message): Message {
const copy: Message = { role: m.role, text: m.text }
if (m.parts) copy.parts = m.parts.map(p => ({ ...p }))
return copy
}
+177
View File
@@ -0,0 +1,177 @@
/**
* DEV BENCH — NOT a test, NOT production code. Throwaway memory-measurement
* harness for tuning the rolling `HERMES_TUI_MAX_MESSAGES` cap. Mounts the
* production `<App store={createSessionStore()}>` under the `@opentui/solid` test
* renderer and samples `process.memoryUsage()` + the mounted-renderable count +
* `getAllocatorStats().activeAllocations`, forcing `global.gc()` before each
* sample. Excluded from the test run (not a *.test.ts) and lint-clean.
*
* It pushes a REALISTIC heavy-session fixture (scripts/fixture.ts) — varied user
* turns + fat multi-part assistant turns (markdown + reasoning + several tool
* headers) — because per-message size varies hugely, so message-count is only a
* LOOSE memory proxy and we're choosing a cap default.
*
* node scripts/build.mjs scripts/mem-bench.tsx .bench # build once (Solid+TS → JS)
* Uncapped: MEM_BENCH_TOTAL=8000 HERMES_TUI_MAX_MESSAGES=100000 \
* node --experimental-ffi --expose-gc --no-warnings .bench/mem-bench.js
* Capped: MEM_BENCH_TOTAL=8000 HERMES_TUI_MAX_MESSAGES=1500 \
* node --experimental-ffi --expose-gc --no-warnings .bench/mem-bench.js
*
* Run each cap as a SEPARATE node invocation so the WASM/native heap starts fresh.
* The matrix loop:
* for cap in 400 1500 3000 6000 100000; do \
* MEM_BENCH_TOTAL=8000 HERMES_TUI_MAX_MESSAGES=$cap \
* node --experimental-ffi --expose-gc --no-warnings .bench/mem-bench.js; done
*
* Signal: native `getAllocatorStats().activeAllocations` (the Zig-side allocator
* count — every live renderable/Yoga subtree contributes) and the recursive
* renderable descendant count under `renderer.root`. RSS is reported too but is
* noisy and grow-only (WASM linear memory never returns to the OS), so the
* meaningful comparison is the STEADY-STATE plateau: capped should flatten after
* ~CAP messages; uncapped should keep climbing.
*
* GC: forces `global.gc()` (synchronous) before each sample to measure RETAINED
* memory, not garbage — run Node with `--expose-gc` or the GC call is a no-op.
*
* RESUME PATH: after the live push matrix, builds the full fixture as a settled
* Message[] and `commitSnapshot`s it (the resume path), reporting mounted nodes +
* RSS — verifying the slice-before-set fix bounds resume mounting to ≤ cap.
*/
import { resolveRenderLib } from '@opentui/core'
import type { Renderable } from '@opentui/core'
import { testRender } from '@opentui/solid'
import { createSessionStore } from '../src/logic/store.ts'
import { App } from '../src/view/App.tsx'
import { ThemeProvider } from '../src/view/theme.tsx'
import { applyTurn, materialize, rowsPerTurn } from './fixture.ts'
const lib = resolveRenderLib()
const TOTAL = Number.parseInt(process.env.MEM_BENCH_TOTAL ?? '8000', 10)
const SAMPLE_EVERY = Number.parseInt(process.env.MEM_BENCH_SAMPLE ?? '500', 10)
const cap = process.env.HERMES_TUI_MAX_MESSAGES ?? '(default 400)'
const MB = (bytes: number) => (bytes / 1024 / 1024).toFixed(1)
/** Force a synchronous full GC to measure RETAINED memory. No-op without `node --expose-gc`. */
const forceGc = (): void => {
const gc = (globalThis as { gc?: () => void }).gc
if (gc) gc()
}
/** Recursively count every Renderable under root (a proxy for live Yoga nodes). */
function descendantCount(node: Renderable): number {
let n = 0
for (const child of node.getChildren()) n += 1 + descendantCount(child)
return n
}
async function main(): Promise<void> {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
const setup = await testRender(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ width: 100, height: 40, exitOnCtrlC: false }
)
await setup.renderOnce()
await setup.flush()
process.stdout.write(
`\n=== mem-bench (REALISTIC fixture) cap=${cap} total=${TOTAL} sampleEvery=${SAMPLE_EVERY} ===\n`
)
process.stdout.write(
'pushes | msgs | rss(MB) | heapUsed(MB) | external(MB) | arrayBuf(MB) | activeAllocs | renderables\n'
)
process.stdout.write(
'-------+------+---------+--------------+--------------+--------------+--------------+------------\n'
)
async function sample(pushes: number): Promise<void> {
await setup.renderOnce()
await setup.flush()
forceGc() // synchronous, full GC — measure retained, not garbage
const m = process.memoryUsage()
const alloc = lib.getAllocatorStats()
const renderables = descendantCount(setup.renderer.root)
const cols = [
String(pushes).padStart(6),
String(store.state.messages.length).padStart(4),
MB(m.rss).padStart(7),
MB(m.heapUsed).padStart(12),
MB(m.external).padStart(12),
MB(m.arrayBuffers).padStart(12),
String(alloc.activeAllocations).padStart(12),
String(renderables).padStart(11)
]
process.stdout.write(cols.join(' | ') + '\n')
}
await sample(0)
// Pump turns inline, sampling each time the cumulative produced-row count crosses
// a SAMPLE_EVERY boundary. Sampling is async (renderOnce/flush/gc), so it lives
// in the loop rather than a sync callback. Mounting is synchronous in Solid, so a
// render pass at the boundary reflects the just-pushed turns.
let pushed = 0
let nextSample = SAMPLE_EVERY
let turn = 0
while (pushed < TOTAL) {
applyTurn(store, turn)
pushed += rowsPerTurn(turn)
turn++
if (pushed >= nextSample) {
await sample(Math.min(pushed, TOTAL))
while (nextSample <= pushed) nextSample += SAMPLE_EVERY
}
}
// Tear down the live push tree BEFORE the resume path so its mounted nodes don't
// pollute the process-wide RSS the resume sample reads. (The renderable COUNT is
// already isolated per-renderer-root, but RSS is process-global.)
store.clearTranscript()
setup.renderer.destroy()
forceGc()
// ── RESUME PATH: build the full settled fixture and commitSnapshot it (the
// resume hydrate path). Verifies the slice-before-set fix bounds resume mounting
// to ≤ cap — mounting 8000 settled msgs at cap=1500 should mount ~1500-worth of
// rows, NOT 8000-worth. Done on a FRESH store + renderer so the live-push history
// above doesn't skew the count.
const resumeStore = createSessionStore()
resumeStore.apply({ type: 'gateway.ready' })
const resumeSetup = await testRender(
() => (
<ThemeProvider theme={() => resumeStore.state.theme}>
<App store={resumeStore} />
</ThemeProvider>
),
{ width: 100, height: 40, exitOnCtrlC: false }
)
await resumeSetup.renderOnce()
await resumeSetup.flush()
const fullFixture = materialize(TOTAL)
resumeStore.beginBuffer()
resumeStore.commitSnapshot(fullFixture)
await resumeSetup.renderOnce()
await resumeSetup.flush()
forceGc()
const rm = process.memoryUsage()
const ralloc = lib.getAllocatorStats()
const rrenderables = descendantCount(resumeSetup.renderer.root)
process.stdout.write('\n--- resume path (commitSnapshot of the full fixture) ---\n')
process.stdout.write(`fixture msgs built : ${fullFixture.length}\n`)
process.stdout.write(`mounted msgs (cap) : ${resumeStore.state.messages.length}\n`)
process.stdout.write(`mounted renderables: ${rrenderables}\n`)
process.stdout.write(`activeAllocations : ${ralloc.activeAllocations}\n`)
process.stdout.write(`rss(MB) : ${MB(rm.rss)}\n`)
resumeSetup.renderer.destroy()
}
await main()