# 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 `~`): ```bash 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: 1. switched alt-screen 2. turned on capability probes (`privateCapabilityRepliesActive`) 3. written those probes through **the same** `lib.writeOut` as 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) 1. Rebuild `neuron-tui` native for this machine; keep `core-darwin-arm64` in workspace. 2. In `packages/tui` `createCliRenderer`: `useThread: false` until skip is logged. 3. After Solid `render()`, `renderer.start()`. 4. First paint: high-contrast text, not the black wordmark. 5. 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.