11 KiB
Fishbone: black TUI, split starts, leftover OpenCode
Fix bottom to top. Do not start at “blank screen” or a new script/dev.ts. Each layer can look like the same black window.
Date: 2026-08-24
Repo: neuron
Kit: sibling neuron-tui
Symptom: iTerm looks empty after launching the CLI, or the CLI dies before that with a loud error.
Unproven (do not skip)
We have not shown that a high-contrast cell landed in your iTerm alternate screen on the current command.
iTerm “session contents” (AppleScript) is usually the main buffer. The TUI draws on the alt buffer. Empty contents is not proof of a blank app.
Until a bright first frame or a dump of the renderer buffer, “always blank” is several failures wearing one coat.
Core lizard (cyclomatic)
Tool: lizard (CCN = cyclomatic complexity). Default warn is 15. Run 2026-08-24 on @neuron-tui/core + native Zig.
The JS presentation host is too big for lizard to see. renderer.ts is 5130 lines. Lizard reports avg CCN 1.9 and 0 warnings. That is a lie: it swallowed most of CliRenderer into resolveModes (595 lines). zig.ts is 6706 lines and lizard found 5 functions. If a metric cannot split the file, the file is the problem.
Hand count of the JS hot path (if/else/switch + &&/||): loop ~32 branches / 122 nloc; requestRender ~10; setupTerminal ~14; constructor ~39. Not 1.9.
Native is where lizard can see, and one function owns present:
| CCN | nloc | fn | file |
|---|---|---|---|
| 189 | 394 | prepareRenderFrameWithWriter |
native/src/renderer.zig |
| 104 | 240 | checkEnvironmentOverrides |
native/src/terminal.zig |
| 58 | 107 | processCapabilityResponse |
terminal.zig |
| 57 | 109 | writeSnapshotCommit |
renderer.zig |
| 47 | 68 | writeKittyImages |
renderer.zig |
prepareRenderFrameWithWriter is 12× the warn line. It is the function that turns the JS buffer into TTY bytes. Skip lives next door: prepareFrame tryLock miss → finishSkippedFrame clears the buffer JS just drew. renderer-output.zig looks simple (avg CCN 2.6). The skip is small. The present is not.
JS outliers that are real parsers, not the blank path: Markdown.createInitialStyledText CCN 366 (lizard may have eaten the class), toNativeAudioStreamFormat 101, parseAlign 89, parseKeypress 84, parsePositiveDecimalPrefix 77.
Fix from this list first: split/log prepareRenderFrameWithWriter; do not wipe nextRenderBuffer on skip; do not run checkEnvironmentOverrides + alt-screen + first frame on the same mutex; split CliRenderer until lizard can name loop / requestRender / setupTerminal.
Bottom → top (fix in this order)
0. Hold one test still
This chat mixed runs: Cursor aborted serve, AppleScript 0×0 windows, fake script/dev.ts, OpenTUI then neuron-tui, port 4096 leftovers. “Still blank” compared different stacks.
Fix: one command, real iTerm (not Cursor job), leave the engine alone, do not AppleScript-scrape alt-screen.
Current command (from any folder, including ~):
bun --cwd /Users/will/Development/neuron-technologies/neuron/packages/cli --conditions=browser src/index.ts
--cwd packages/cli is required so packages/cli/bunfig.toml preloads @neuron-tui/solid/preload. That cwd also makes process.cwd() = packages/cli (the “project” the engine/TUI think you opened). That is a later layer.
1. Core: native library (neuron-tui)
What: Zig renderer. On darwin arm64 JS does import("@neuron-tui/core-darwin-arm64").
Fact: that name 404s on npm. OpenTUI’s @opentui/core-darwin-arm64 is published. Rename did not ship binaries.
Error (loud): Failed to initialize OpenTUI render library: Cannot find package '@neuron-tui/core-darwin-arm64' — leftover OpenTUI string in your core.
Done locally: workspace package neuron-tui/packages/core-darwin-arm64 copies packages/native/lib/aarch64-macos/libopentui.dylib (built ~Aug 22). CLI/TUI depend on it.
Still open: JS is today; dylib is another day. ABI mismatch → black, no JS throw. Rebuild native from current neuron-tui when fixing core for real. Other platforms still 404 (linux/win optional deps).
2. Core: Mac thread vs Linux, skip vs fail
What: CliRenderer constructor sets useThread: true except Linux (forced off; comment: thread crashes). You are on Mac.
Two output machines:
| Path | What happens on native render status SKIPPED (1) |
|---|---|
| Tests / custom stdout | NativeSpanFeed → wait feed.idle() → retry |
iTerm process.stdout + thread |
No feed. stdout.write hijacked to lib.writeOut. Skip → "backpressured", no console.error |
Alt-screen is lib.setupTerminal(..., alternate-screen) — wipe is native, before Solid has a tree.
Fix direction: useThread: false on darwin until skip is visible; or log skip; or use feed on TTY; rebuild native; prove lib.render returns presented not skipped.
3. Core: idle one-shot vs live loop
What: @neuron-tui/solid render() does engine.attach (timeline) + mount. Tests call renderer.start(). The app does not.
Paint is requestRender() → one activateFrame if _isRunning is false.
activateFrame no-ops if updateScheduled is already false.
internalStart() clears updateScheduled so queued one-shots die when the live loop starts. Idle shots and the continuous loop cancel each other by design.
Fix direction: renderer.start() (or requestLive) after mount so skipped frame 0 is not the only chance. Do not “fix” this by adding another wrapper script.
4. Core: queries and pixels on the same hijack
setupTerminal returns immediately. It has already:
- switched alt-screen
- turned on capability probes (
privateCapabilityRepliesActive) - written those probes through the same
lib.writeOutas frames
iTerm replies on stdin. Frame 0 is process.nextTick. Probes and first paint contend. Skip on frame 0 is “queue full,” not “JS forgot to draw.” The 5s timer is stop asking, not don’t draw yet.
Constructor also replaces global requestAnimationFrame. If the renderer is idle, rAF UI sleeps too.
Fix direction: don’t probe on the presentation path; or don’t hijack stdout until after first present; or useThread: false so stdout.write is real TTY again.
5. Errors after core owns stdout can vanish
Loud errors you pasted (Failed to start server, react, missing dylib) happened before hijack.
Bootstrap (sync.tsx) uses throwOnError: true and exit(e) after the TUI is up. Those logs can go into Zig. No ERROR (#1) + black ≠ no error.
Fix direction: log bootstrap failures to a file, or restore realStdoutWrite for console.error.
6. --conditions=browser is a pun
Needed: solid-js client build (dist/solid.js), not dist/server.js.
Also: Bun treats it as “browser app” → default JSX factory React.
--cwd repo skips packages/cli/bunfig.toml → no @neuron-tui/solid/preload → Cannot find package 'react'. Root bunfig preload failed: @opentui/solid/preload not installed at root (and that is not your kit).
Fix direction: keep CLI bunfig preload; do not put kit preload on repo root; or pass --jsx-import-source @neuron-tui/solid explicitly; split “solid client resolve” from “browser JSX.”
7. One --cwd, three jobs
| Job | Wants |
|---|---|
| bunfig / Solid preload | packages/cli |
process.cwd() = project for engine/TUI |
neuron repo (or the user’s real project) |
Finding src/index.ts |
a path that exists from this shell (~ vs repo) |
--cwd packages/cli from ~ → ENOENT (packages/cli is relative).
--cwd repo → JSX React.
Old script/dev.ts climbed twice (URL .. then dirname) → neuron-technologies/ not neuron/. Deleted; do not bring it back.
Fix direction: absolute CLI path + set process cwd to the repo after bun loads bunfig, or bun --cwd cli plus NEURON_CWD / engine cwd override. Never one relative --cwd for all three.
8. Two bosses for “is the engine up”
CLI daemon: alive = ~/.neuron/server.json and /global/health.
A live bun ./src/serve.ts on 4096 with no file = “no server.” Child spawn uses stdio: ignore. Wait → Failed to start server.
Daemon start() now probes default http://127.0.0.1:4096 if registration is missing.
Fix direction: one authority (health on the port, or always --register). Never hide child stderr. Don’t spawn a second listener on 4096.
9. Painted empty ≠ unpainted
packages/tui is still OpenCode’s client (many HTTP routes, plugins, home layout). Neuron answers a lot with empties so the client won’t invent data.
Home: two flexGrow={1} spacers, 7-row logo, prompt. Wordmark PNG is cream on black; app clear #0a0a0a. StartupLoading only appears if ready() is false after 500ms; App ready starts true → no spinner.
A successful frame can look dead.
Settings ~/.local/state/opencode/kv.json used to hide the whole tree until a flock lock finished (createSimpleContext + Show when ready). That gate was removed; file is optional. Do not treat kv as the remaining blank.
Logo protocol="auto" (Kitty/iTerm handshake) could hang with no ASCII fallback. Now protocol="blocks" on the same PNG. Contrast still terrible.
Fix direction: first frame high contrast (e.g. yellow “neuron” on black) before logo/image; renderer.start(); then put the wordmark back. Do not use alt-screen scrape as pass/fail.
10. Product vs kit (do not confuse)
| Piece | What it is |
|---|---|
neuron-tui |
Your kit (Zig + Solid reconciler). Not the Neuron chat app. |
packages/tui |
OpenCode screens now importing @neuron-tui/*. |
packages/cli |
Launcher + daemon; still @opencode-ai/cli. |
packages/neuron |
Engine HTTP on 4096. |
script/dev.ts |
Fake one-command runner. Deleted. Do not restore. |
Root package.json workspaces include sibling ../neuron-tui/packages/{core,solid,keymap,react,core-darwin-arm64}. Nested workspace + npm 404s on other core-* platforms remain.
Loud errors already explained (not “blank”)
| Paste | Cause |
|---|---|
Could not change directory to "packages/cli" |
Relative --cwd from ~ |
Failed to start server |
Daemon vs leftover 4096 / no server.json |
Cannot find package 'react' |
JSX default React; CLI bunfig not loaded |
preload not found "@opentui/solid/preload" |
Root bunfig; not your kit |
Cannot find package '@neuron-tui/core-darwin-arm64' |
Native never published; now local package |
Not the user’s fault
Did not delete packages/cli. Did not need React. Did not “fail to use the TS build.” Bun/package.json already was that. Did not turn on a blank-screen mode.
Suggested first patches (still bottom-up)
- Rebuild
neuron-tuinative for this machine; keepcore-darwin-arm64in workspace. - In
packages/tuicreateCliRenderer:useThread: falseuntil skip is logged. - After Solid
render(),renderer.start(). - First paint: high-contrast text, not the black wordmark.
- Then: one cwd story, engine health-only, bootstrap errors to a file.
When those five are true in one iTerm run, re-judge “blank.” Not before.